diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index 5166a39..c571f8b 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -827,6 +827,10 @@ SMB: /mnt/isolation +
+ + Stash: Checking... +
SQLite: 0 Cached @@ -940,6 +944,39 @@
+ +
+
+
+ ⚡ Stash App Integration (servervm.local:9999) +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -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()