Enhancement: Direct Stash instance integration with scene performer matching, female prioritization & auto-lookup
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user