@@ -1050,6 +1087,10 @@
Fetch Torrents from /downloads
+
+
+
+
`;
}).join('');
} else {
- pillsHtml = `
No AI analysis performed yet:
-
`;
+ pillsHtml = `
No analysis performed yet:
+
+
`;
}
const destFolder = item.targetPath || (item.selectedPerformer ? (type === 'smb' ? `/mnt/isolation/videos/${item.selectedPerformer}` : `/isolation/videos/${item.selectedPerformer}`) : '(No folder selected)');
@@ -3756,6 +3985,31 @@ Return ONLY a valid JSON object with an array named "results":
});
}
+ const btnStashNow = videoPerformerBox.querySelector('#btn-modal-stash-now');
+ if (btnStashNow) {
+ btnStashNow.addEventListener('click', async () => {
+ btnStashNow.disabled = true;
+ btnStashNow.textContent = 'Matching...';
+ try {
+ const count = await matchItemsWithStash([item], type);
+ if (count > 0) {
+ showToast(`Matched with Stash: ${item.selectedPerformer}`, 'success');
+ if (type === 'smb') renderSmbRow(item.path);
+ else renderRow(item.hash);
+ renderVideoModalPerformerControls(item, type);
+ } else {
+ showToast('No match found in Stash for this video', 'info');
+ btnStashNow.disabled = false;
+ btnStashNow.textContent = '⚡ Match Stash';
+ }
+ } catch (e) {
+ showToast(`Stash lookup error: ${e.message}`, 'error');
+ btnStashNow.disabled = false;
+ btnStashNow.textContent = '⚡ Match Stash';
+ }
+ });
+ }
+
const btnAnalyzeNow = videoPerformerBox.querySelector('#btn-modal-analyze-now');
if (btnAnalyzeNow) {
btnAnalyzeNow.addEventListener('click', async () => {
@@ -3865,6 +4119,7 @@ Return ONLY a valid JSON object with an array named "results":
window.addEventListener('DOMContentLoaded', async () => {
loadConfig();
await loadDatabaseCache();
+ await checkStashStatus();
await loadExistingPerformers();
if (window.location.protocol.startsWith('http')) {
try {
diff --git a/server.py b/server.py
index 2045733..1375d16 100644
--- a/server.py
+++ b/server.py
@@ -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()