feat: Add Stash Performer Image Review tab with AI/web candidate photo finder, interactive visual review grid, and batch commit to Stash

This commit is contained in:
david
2026-08-17 10:46:46 -04:00
parent f553c4d02e
commit a273804dde
2 changed files with 995 additions and 13 deletions
+271 -2
View File
@@ -383,6 +383,64 @@ def query_stash_batch(items, stash_url=None):
return results
def search_performer_candidate_images(name, aliases=None, limit=6):
queries = [
f"{name} actress portrait",
f"{name} model photoshoot",
f"{name} portrait"
]
if aliases and isinstance(aliases, list):
for a in aliases[:2]:
if a and a.lower() != name.lower():
queries.append(f"{a} actress portrait")
results = []
seen = set()
for q in queries:
url = f"https://www.bing.com/images/search?q={urllib.parse.quote(q)}&FORM=HDRSC2"
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}
)
try:
with urllib.request.urlopen(req, timeout=6) as res:
html = res.read().decode('utf-8', errors='ignore')
murls = re.findall(r'"murl":"(http[^&]+)"', html)
turls = re.findall(r'"turl":"(http[^&]+)"', html)
for i, u in enumerate(murls):
clean_u = u.replace('\\/', '/')
if clean_u not in seen:
seen.add(clean_u)
t = turls[i].replace('\\/', '/') if i < len(turls) else clean_u
results.append({'url': clean_u, 'thumbnail': t})
if len(results) >= limit:
break
except Exception:
pass
if len(results) >= limit:
break
return results
def fetch_image_as_base64(image_url):
req = urllib.request.Request(
image_url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': image_url
}
)
with urllib.request.urlopen(req, timeout=12) as resp:
content_type = resp.headers.get('Content-Type', 'image/jpeg')
if not content_type or not content_type.startswith('image/'):
content_type = 'image/jpeg'
raw_data = resp.read()
b64_str = base64.b64encode(raw_data).decode('utf-8')
return f"data:{content_type};base64,{b64_str}"
def normalize_host(host_str):
if not host_str:
return DEFAULT_QB_HOST
@@ -558,9 +616,13 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
# Performer Image Proxy API
elif self.path.startswith('/api/performer/image'):
self.handle_performer_image()
# Stash Status API
elif self.path.startswith('/api/image/proxy'):
self.handle_image_proxy()
# Stash Status & Performer Review APIs
elif self.path.startswith('/api/stash/status'):
self.handle_stash_status()
elif self.path.startswith('/api/stash/performers/missing-images'):
self.handle_stash_missing_images()
# SMB API
elif self.path.startswith('/api/smb/status'):
self.handle_smb_status()
@@ -596,11 +658,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_db_batch_save()
elif self.path == '/api/db/delete':
self.handle_db_delete()
# Stash Lookup APIs
# Stash Lookup & Image Review APIs
elif self.path == '/api/stash/lookup':
self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup()
elif self.path == '/api/stash/performers/find-images':
self.handle_stash_find_images()
elif self.path == '/api/stash/performers/commit-image':
self.handle_stash_commit_image()
elif self.path == '/api/stash/performers/batch-commit-images':
self.handle_stash_batch_commit_images()
# SMB API Moves & Deletes
elif self.path == '/api/smb/move':
self.handle_smb_move()
@@ -995,6 +1063,36 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception:
pass
def handle_image_proxy(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
target_url = params.get('url', [''])[0]
if not target_url:
self.send_response(400)
self.end_headers()
return
try:
req = urllib.request.Request(
target_url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': target_url
}
)
with urllib.request.urlopen(req, timeout=10) as resp:
content_type = resp.headers.get('Content-Type', 'image/jpeg')
img_data = resp.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Cache-Control', 'public, max-age=86400')
self.send_cors_headers()
self.end_headers()
self.wfile.write(img_data)
except Exception:
self.send_response(404)
self.end_headers()
# ==================== Stash Server API Handlers ====================
def handle_stash_status(self):
parsed = urllib.parse.urlparse(self.path)
@@ -1073,6 +1171,177 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "results": []}).encode('utf-8'))
def handle_stash_missing_images(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('url', [''])[0] or DEFAULT_STASH_URL
gender_filter = params.get('gender', ['FEMALE'])[0].upper()
only_scenes = params.get('has_scenes', ['true'])[0].lower() in ['true', '1']
query = '''
query {
allPerformers {
id
name
disambiguation
gender
image_path
scene_count
image_count
alias_list
country
birthdate
}
}
'''
try:
res = execute_stash_graphql(query, stash_url, timeout=15)
all_p = res.get('data', {}).get('allPerformers', [])
missing = []
for p in all_p:
img_path = p.get('image_path') or ''
is_missing = not img_path or 'default=true' in img_path or 'default' in img_path.lower()
if not is_missing:
continue
p_gender = (p.get('gender') or '').upper()
if gender_filter != 'ALL':
if gender_filter == 'FEMALE' and p_gender not in ['FEMALE', '', 'NONE', None]:
continue
elif gender_filter != 'FEMALE' and p_gender != gender_filter:
continue
scenes = p.get('scene_count') or 0
if only_scenes and scenes <= 0:
continue
missing.append(p)
# Sort: performers with more scenes first, then by name
missing.sort(key=lambda x: (-(x.get('scene_count') or 0), x.get('name', '').lower()))
self.send_json_response(200, {
"performers": missing,
"count": len(missing),
"total_in_stash": len(all_p)
})
except Exception as e:
self.send_json_response(500, {"error": f"Failed fetching performers from Stash: {str(e)}", "performers": []})
def handle_stash_find_images(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'))
name = payload.get('name', '')
aliases = payload.get('aliases', [])
candidates = search_performer_candidate_images(name, aliases)
self.send_json_response(200, {
"name": name,
"candidates": candidates,
"count": len(candidates)
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "candidates": []})
def handle_stash_commit_image(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'))
performer_id = str(payload.get('id', ''))
image_url = payload.get('image_url', '')
base64_data = payload.get('base64', '')
stash_url = payload.get('stash_url', '') or DEFAULT_STASH_URL
if not performer_id:
self.send_json_response(400, {"error": "Missing performer ID"})
return
if not base64_data and image_url:
base64_data = fetch_image_as_base64(image_url)
if not base64_data:
self.send_json_response(400, {"error": "Missing image data or URL"})
return
mutation = f'''
mutation {{
performerUpdate(input: {{ id: {json.dumps(performer_id)}, image: {json.dumps(base64_data)} }}) {{
id
name
image_path
}}
}}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if 'errors' in res and res['errors']:
err_msg = res['errors'][0].get('message', 'GraphQL error')
self.send_json_response(500, {"error": err_msg})
return
updated = res.get('data', {}).get('performerUpdate', {})
self.send_json_response(200, {
"success": True,
"id": performer_id,
"name": updated.get('name'),
"image_path": updated.get('image_path')
})
except Exception as e:
self.send_json_response(500, {"error": f"Failed committing image to Stash: {str(e)}"})
def handle_stash_batch_commit_images(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'))
updates = payload.get('updates', [])
stash_url = payload.get('stash_url', '') or DEFAULT_STASH_URL
results = []
for item in updates:
p_id = str(item.get('id', ''))
img_url = item.get('image_url', '')
b64 = item.get('base64', '')
try:
if not b64 and img_url:
b64 = fetch_image_as_base64(img_url)
if not b64:
results.append({"id": p_id, "success": False, "error": "No image data"})
continue
mutation = f'''
mutation {{
performerUpdate(input: {{ id: {json.dumps(p_id)}, image: {json.dumps(b64)} }}) {{
id
name
image_path
}}
}}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if 'errors' in res and res['errors']:
err_msg = res['errors'][0].get('message', 'GraphQL error')
results.append({"id": p_id, "success": False, "error": err_msg})
else:
updated = res.get('data', {}).get('performerUpdate', {})
results.append({
"id": p_id,
"success": True,
"name": updated.get('name'),
"image_path": updated.get('image_path')
})
except Exception as ex:
results.append({"id": p_id, "success": False, "error": str(ex)})
self.send_json_response(200, {
"results": results,
"total": len(updates),
"successful": len([r for r in results if r.get('success')])
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "results": []})
# ==================== SMB File Management Handlers ====================
def handle_smb_status(self):
mounted, msg = ensure_smb_mounted()