Enhancement: Direct Stash instance integration with scene performer matching, female prioritization & auto-lookup

This commit is contained in:
david
2026-08-17 09:20:31 -04:00
parent 6aadc53335
commit 8e16c362b9
2 changed files with 561 additions and 6 deletions
+300
View File
@@ -66,6 +66,208 @@ def get_db_connection():
conn.row_factory = sqlite3.Row
return conn
# Stash Server Integration
DEFAULT_STASH_URL = "http://servervm.local:9999/graphql"
DEFAULT_STASH_SQLITE = "/mnt/stash_appdata/stashapp/config/stash-go.sqlite"
def normalize_stash_url(url_str):
if not url_str:
return DEFAULT_STASH_URL
url_str = url_str.strip().rstrip('/')
if not url_str.startswith('http://') and not url_str.startswith('https://'):
url_str = 'http://' + url_str
if not url_str.endswith('/graphql'):
url_str = url_str + '/graphql'
if 'servervm.local' in url_str:
try:
ip = socket.gethostbyname('servervm.local')
url_str = url_str.replace('servervm.local', ip)
except Exception:
pass
return url_str
def format_performer_slug(raw_name):
if not raw_name:
return ''
clean = re.sub(r'[/\\?%*:|"<>]+', '', raw_name.strip()).lower()
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):
endpoint = normalize_stash_url(stash_url)
req = urllib.request.Request(
endpoint,
data=json.dumps({'query': query_str}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=timeout) as res:
return json.loads(res.read().decode())
def parse_stash_scene_data(scene, match_strategy='exact_path'):
if not scene:
return None
performers = scene.get('performers', [])
if not performers:
return None
females = [p['name'] for p in performers if p.get('gender') == 'FEMALE']
males = [p['name'] for p in performers if p.get('gender') == 'MALE']
others = [p['name'] for p in performers if p.get('gender') not in ('FEMALE', 'MALE')]
# Priority: Females first as primary, then others, then males
ordered_names = females + others + males
if not ordered_names:
ordered_names = [p['name'] for p in performers]
formatted_candidates = []
seen = set()
for name in ordered_names:
fmt = format_performer_slug(name)
if fmt and fmt not in seen:
seen.add(fmt)
formatted_candidates.append({'raw': name, 'formatted': fmt})
if not formatted_candidates:
return None
primary_formatted = formatted_candidates[0]['formatted']
studio_name = scene.get('studio', {}).get('name') if scene.get('studio') else None
title = scene.get('title') or ''
scene_id = scene.get('id') or ''
reasoning = f'Stash Verified: Scene #{scene_id}'
if title:
reasoning += f' "{title}"'
if studio_name:
reasoning += f' ({studio_name})'
return {
'stash_id': scene_id,
'title': title,
'studio': studio_name,
'primaryPerformer': primary_formatted,
'allPerformers': formatted_candidates,
'femalePerformers': [format_performer_slug(n) for n in females],
'malePerformers': [format_performer_slug(n) for n in males],
'confidence': 'verified',
'reasoning': reasoning,
'matchStrategy': match_strategy
}
def query_stash_single(file_name, file_path=None, item_type='smb', stash_url=None):
basename = os.path.basename(file_path or file_name)
stash_paths = [
f'/data/videodownloader/{basename}',
f'/data/{basename}'
]
# 1. Exact path match in Stash
for p in stash_paths:
q = f'''
{{
findScenes(scene_filter: {{path: {{value: {json.dumps(p)}, modifier: EQUALS}}}}) {{
scenes {{
id
title
performers {{
name
gender
}}
studio {{
name
}}
}}
}}
}}
'''
try:
res = execute_stash_graphql(q, stash_url, timeout=3)
scenes = res.get('data', {}).get('findScenes', {}).get('scenes', [])
if scenes and scenes[0].get('performers'):
parsed = parse_stash_scene_data(scenes[0], 'exact_path')
if parsed:
return parsed
except Exception:
pass
# 2. Basename search in Stash
stem = os.path.splitext(basename)[0]
q = f'''
{{
findScenes(filter: {{q: {json.dumps(stem[:35])}, per_page: 3}}) {{
scenes {{
id
title
files {{
basename
}}
performers {{
name
gender
}}
studio {{
name
}}
}}
}}
}}
'''
try:
res = execute_stash_graphql(q, stash_url, timeout=3)
scenes = res.get('data', {}).get('findScenes', {}).get('scenes', [])
if scenes:
for s in scenes:
if s.get('performers'):
parsed = parse_stash_scene_data(s, 'fuzzy_match')
if parsed:
return parsed
except Exception:
pass
return None
def query_stash_batch(items, stash_url=None):
if not items:
return []
queries = []
for idx, item in enumerate(items):
file_path = item.get('path') or item.get('name')
basename = os.path.basename(file_path)
p = f'/data/videodownloader/{basename}'
queries.append(f'item_{idx}: findScenes(scene_filter: {{path: {{value: {json.dumps(p)}, modifier: EQUALS}}}}) {{ scenes {{ id title performers {{ name gender }} studio {{ name }} }} }}')
batch_q = '{\n' + '\n'.join(queries) + '\n}'
results = [None] * len(items)
unmatched_indices = []
try:
res = execute_stash_graphql(batch_q, stash_url, timeout=6)
data = res.get('data', {})
for idx in range(len(items)):
scenes = data.get(f'item_{idx}', {}).get('scenes', [])
if scenes and scenes[0].get('performers'):
parsed = parse_stash_scene_data(scenes[0], 'exact_path')
if parsed:
results[idx] = parsed
if not results[idx]:
unmatched_indices.append(idx)
except Exception as e:
print(f"Batch exact Stash query failed: {e}")
unmatched_indices = list(range(len(items)))
# Second pass for unmatched items
for idx in unmatched_indices:
item = items[idx]
file_name = item.get('name', '')
file_path = item.get('path', '')
item_type = item.get('item_type', 'smb')
match = query_stash_single(file_name, file_path, item_type, stash_url)
results[idx] = match
return results
def normalize_host(host_str):
if not host_str:
return DEFAULT_QB_HOST
@@ -144,6 +346,9 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
# Existing Performer Directories Intellisense API
elif self.path.startswith('/api/performers'):
self.handle_list_performers()
# Stash Status API
elif self.path.startswith('/api/stash/status'):
self.handle_stash_status()
# SMB API
elif self.path.startswith('/api/smb/status'):
self.handle_smb_status()
@@ -165,6 +370,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
"target": DEFAULT_QB_HOST,
"has_sid": bool(latest_sid),
"smb_mounted": os.path.exists(DEFAULT_SMB_SOURCE),
"stash_url": DEFAULT_STASH_URL,
"db": os.path.exists(DB_PATH)
}
self.wfile.write(json.dumps(status_data).encode('utf-8'))
@@ -180,6 +386,11 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_db_save()
elif self.path == '/api/db/batch-save':
self.handle_db_batch_save()
# Stash Lookup APIs
elif self.path == '/api/stash/lookup':
self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup()
# SMB API Moves
elif self.path == '/api/smb/move':
self.handle_smb_move()
@@ -498,6 +709,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e:
print(f"Error merging performers from DB: {e}")
# Also merge performers from Stash database if online
try:
stash_q = '{ allPerformers { name } }'
res = execute_stash_graphql(stash_q, timeout=2)
for p in res.get('data', {}).get('allPerformers', []):
slug = format_performer_slug(p.get('name'))
if slug:
performers_set.add(slug)
except Exception as e:
pass
performers = sorted(list(performers_set), key=lambda s: s.lower())
self.send_response(200)
self.send_header('Content-Type', 'application/json')
@@ -505,6 +727,84 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps({"performers": performers, "count": len(performers)}).encode('utf-8'))
# ==================== Stash Server API Handlers ====================
def handle_stash_status(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('url', [''])[0] or DEFAULT_STASH_URL
try:
q = '{ version { version } stats { scene_count performer_count } }'
res = execute_stash_graphql(q, stash_url, timeout=3)
data = res.get('data', {})
version = data.get('version', {}).get('version', 'unknown')
scene_count = data.get('stats', {}).get('scene_count', 0)
performer_count = data.get('stats', {}).get('performer_count', 0)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({
"online": True,
"version": version,
"sceneCount": scene_count,
"performerCount": performer_count,
"url": normalize_stash_url(stash_url)
}).encode('utf-8'))
except Exception as e:
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({
"online": False,
"error": str(e),
"url": normalize_stash_url(stash_url)
}).encode('utf-8'))
def handle_stash_lookup(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
req_data = json.loads(body)
file_name = req_data.get('name', '')
file_path = req_data.get('path', '')
item_type = req_data.get('item_type', 'smb')
stash_url = req_data.get('stash_url', '')
match = query_stash_single(file_name, file_path, item_type, stash_url)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"match": match, "found": bool(match)}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "match": None, "found": False}).encode('utf-8'))
def handle_stash_batch_lookup(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
req_data = json.loads(body)
items = req_data.get('items', [])
stash_url = req_data.get('stash_url', '')
results = query_stash_batch(items, stash_url)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"results": results, "count": len(results)}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "results": []}).encode('utf-8'))
# ==================== SMB File Management Handlers ====================
def handle_smb_status(self):
mounted, msg = ensure_smb_mounted()