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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user