feat: Add Stash Scene Duplicate Comparator & Plugin Automation Hub to Tab 5

This commit is contained in:
david
2026-08-18 09:23:22 -04:00
parent e354b52652
commit 1a0e7f619a
2 changed files with 470 additions and 3 deletions
+169 -3
View File
@@ -198,11 +198,14 @@ def format_performer_slug(raw_name):
parts = [p for p in re.split(r'[\s_\-\.]+', clean) if p]
return '.'.join(parts)
def execute_stash_graphql(query_str, stash_url=None, timeout=4):
def execute_stash_graphql(query_str, stash_url=None, variables=None, timeout=4):
endpoint = normalize_stash_url(stash_url)
payload = {'query': query_str}
if variables:
payload['variables'] = variables
req = urllib.request.Request(
endpoint,
data=json.dumps({'query': query_str}).encode('utf-8'),
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=timeout) as res:
@@ -789,11 +792,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_performer_image()
elif self.path.startswith('/api/image/proxy'):
self.handle_image_proxy()
# Stash Status, Tasks & Performer Review APIs
# Stash Status, Tasks, Duplicates & Performer Review APIs
elif self.path.startswith('/api/stash/status'):
self.handle_stash_status()
elif self.path.startswith('/api/stash/tasks/jobs'):
self.handle_stash_tasks_jobs()
elif self.path.startswith('/api/stash/duplicates'):
self.handle_stash_duplicates()
elif self.path.startswith('/api/stash/plugins'):
self.handle_stash_plugins_list()
elif self.path.startswith('/api/stash/performers/missing-images'):
self.handle_stash_missing_images()
# SMB API
@@ -855,6 +862,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_stash_task_generate()
elif self.path == '/api/stash/tasks/stop':
self.handle_stash_task_stop()
elif self.path == '/api/stash/plugins/run-task':
self.handle_stash_run_plugin_task()
elif self.path == '/api/stash/scenes/destroy':
self.handle_stash_scene_destroy()
elif self.path == '/api/stash/performers/find-images':
self.handle_stash_find_images()
elif self.path == '/api/stash/performers/commit-image':
@@ -2148,6 +2159,161 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e:
self.send_json_response(500, {"error": str(e)})
# ==================== Stash Duplicate Checker & Plugin Integration Handlers ====================
def handle_stash_duplicates(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
distance = int(params.get('distance', [0])[0])
duration_diff = float(params.get('duration_diff', [5.0])[0])
query = '''
query FindDuplicateScenes($distance: Int, $duration_diff: Float) {
findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) {
id
title
date
details
rating100
organized
paths {
screenshot
preview
stream
}
files {
id
path
size
duration
video_codec
width
height
bit_rate
frame_rate
}
performers {
id
name
image_path
}
studio {
id
name
image_path
}
tags {
id
name
}
}
}
'''
try:
res = execute_stash_graphql(query, stash_url, variables={"distance": distance, "duration_diff": duration_diff}, timeout=30)
raw_groups = res.get('data', {}).get('findDuplicateScenes') or []
duplicate_groups = []
for group in raw_groups:
if isinstance(group, list) and len(group) > 1:
sorted_group = sorted(group, key=lambda s: (
(s.get('files', [{}])[0].get('height') or 0),
(s.get('files', [{}])[0].get('bit_rate') or 0),
(s.get('files', [{}])[0].get('size') or 0)
), reverse=True)
duplicate_groups.append(sorted_group)
self.send_json_response(200, {
"groups": duplicate_groups,
"group_count": len(duplicate_groups),
"total_duplicate_scenes": sum(len(g) for g in duplicate_groups),
"distance": distance,
"duration_diff": duration_diff
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "groups": []})
def handle_stash_plugins_list(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
query = '''
{
plugins {
id
name
description
version
enabled
tasks {
name
description
}
}
}
'''
try:
res = execute_stash_graphql(query, stash_url, timeout=10)
plugins = res.get('data', {}).get('plugins') or []
self.send_json_response(200, {"plugins": plugins, "count": len(plugins)})
except Exception as e:
self.send_json_response(500, {"error": str(e), "plugins": []})
def handle_stash_run_plugin_task(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
plugin_id = payload.get('plugin_id')
task_name = payload.get('task_name')
if not plugin_id or not task_name:
self.send_json_response(400, {"error": "Missing plugin_id or task_name"})
return
mutation = '''
mutation RunPluginTask($plugin_id: ID!, $task_name: String!) {
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')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_scene_destroy(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
scene_id = payload.get('scene_id')
delete_file = bool(payload.get('delete_file', True))
delete_generated = bool(payload.get('delete_generated', True))
if not scene_id:
self.send_json_response(400, {"error": "Missing scene_id"})
return
mutation = '''
mutation SceneDestroy($input: SceneDestroyInput!) {
sceneDestroy(input: $input)
}
'''
variables = {
"input": {
"id": str(scene_id),
"delete_file": delete_file,
"delete_generated": delete_generated
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('sceneDestroy')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
# ==================== SMB Maintenance & Cleanup Handlers ====================
def handle_smb_scan_cleanup(self):
parsed = urllib.parse.urlparse(self.path)