From 1a0e7f619a4e605465b45d898537358246d8dff9 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 18 Aug 2026 09:23:22 -0400 Subject: [PATCH] feat: Add Stash Scene Duplicate Comparator & Plugin Automation Hub to Tab 5 --- qbittorrent_performer_sorter.html | 301 ++++++++++++++++++++++++++++++ server.py | 172 ++++++++++++++++- 2 files changed, 470 insertions(+), 3 deletions(-) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index 7f99bfa..491a6cc 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -1973,6 +1973,79 @@ + + +
+
+
+
+ ๐Ÿ” Stash Scene Duplicate Comparator & Plugin Automation Hub +
+

+ Compare duplicate scene pairs side-by-side (Quality/Bitrate/Resolution), preview streams, and run automated Stash duplicate taggers/cleaners. +

+
+ + 0 duplicate clusters + +
+ + +
+
+ โšก Stash Installed Plugin One-Click Task Triggers: +
+
+ + + + + + +
+
+ + +
+
+
+ Match Distance: + +
+ + +
+
+ + +
+
+
๐Ÿ”
+

No duplicate scan executed yet

+

Click "Scan Stash Duplicates" to find duplicate scene pairs and compare qualities side-by-side.

+
+
+
+ @@ -6735,6 +6808,234 @@ Return ONLY a valid JSON object with an array named "results": } } + // ==================== STASH DUPLICATE COMPARATOR & PLUGIN RUNNERS ==================== + let duplicateSceneGroups = []; + + async function scanStashDuplicates() { + const btn = document.getElementById('btn-scan-stash-dupes'); + const dist = document.getElementById('stash-dupe-distance')?.value || 0; + const container = document.getElementById('stash-duplicates-container'); + const badge = document.getElementById('stash-dupe-count-badge'); + + try { + if (btn) { btn.disabled = true; btn.textContent = 'Scanning Duplicates...'; } + if (container) { + container.innerHTML = ` +
+
โณ
+

Scanning Stash library for duplicate scene clusters (Distance: ${dist})...

+
+ `; + } + + const res = await fetch(`/api/stash/duplicates?distance=${dist}&duration_diff=5`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + duplicateSceneGroups = data.groups || []; + + if (badge) badge.textContent = `${duplicateSceneGroups.length} duplicate clusters (${data.total_duplicate_scenes || 0} scenes)`; + + renderStashDuplicates(duplicateSceneGroups); + } catch (err) { + showToast(`Duplicate scan error: ${err.message}`, 'error'); + if (container) { + container.innerHTML = `
Failed to scan duplicates: ${err.message}
`; + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = '๐Ÿ” Scan Stash Duplicates'; } + } + } + + function renderStashDuplicates(groups) { + const container = document.getElementById('stash-duplicates-container'); + if (!container) return; + + if (!groups || groups.length === 0) { + container.innerHTML = ` +
+
โœจ
+

No duplicate scenes found

+

Your Stash library has 0 duplicate clusters at this match distance.

+
+ `; + return; + } + + container.innerHTML = groups.map((group, gIdx) => { + return ` +
+
+ + Cluster #${gIdx + 1} (${group.length} Copies) + + + ${escapeHtml(group[0]?.title || 'Untitled Scene')} + +
+ +
+ ${group.map((scene, sIdx) => { + const isBest = sIdx === 0; + const file = (scene.files && scene.files[0]) || {}; + const resLabel = file.height ? `${file.height}p` : 'Unknown'; + const bitrateFormatted = file.bit_rate ? `${(file.bit_rate / 1000000).toFixed(2)} Mbps` : '-'; + const sizeFormatted = file.size ? (file.size >= 1024**3 ? `${(file.size / 1024**3).toFixed(2)} GB` : `${(file.size / 1024**2).toFixed(1)} MB`) : '-'; + const durFormatted = file.duration ? `${Math.floor(file.duration / 60)}m ${Math.floor(file.duration % 60)}s` : '-'; + const performerNames = (scene.performers || []).map(p => p.name).join(', ') || 'Unknown'; + + return ` +
+ +
+ + ${isBest ? '๐Ÿ† Recommended Keeper' : 'โš ๏ธ Lower Quality Duplicate'} + + ID: ${scene.id} +
+ + +
+ ${scene.paths?.screenshot ? `Scene Cover` : ''} + ${durFormatted} +
+ + +
+ ${escapeHtml(scene.title || 'Untitled Scene')} +
+ +
+ ๐Ÿ‘ค ${escapeHtml(performerNames)} + ๐Ÿข ${escapeHtml(scene.studio?.name || '')} +
+ + +
+ ${resLabel} + ${bitrateFormatted} + ${sizeFormatted} + ${file.video_codec || 'video'} +
+ + +
+ ๐Ÿ“ ${escapeHtml(file.path || '')} +
+ + +
+ + ${!isBest ? ` + + ` : ` + + `} +
+ +
+ `; + }).join('')} +
+
+ `; + }).join(''); + + // Attach Preview & Destroy Listeners + container.querySelectorAll('.btn-dupe-preview').forEach(btn => { + btn.addEventListener('click', () => { + const filePath = btn.getAttribute('data-path'); + const title = btn.getAttribute('data-title'); + if (filePath) { + openVideoPreview(filePath, title, null); + } + }); + }); + + container.querySelectorAll('.btn-dupe-destroy').forEach(btn => { + btn.addEventListener('click', async () => { + const sid = btn.getAttribute('data-scene-id'); + const title = btn.getAttribute('data-title'); + if (confirm(`Permanently delete this duplicate scene and its video file from disk?\n\n"${title}" (ID: ${sid})`)) { + await deleteStashDuplicateScene(sid, btn); + } + }); + }); + } + + async function deleteStashDuplicateScene(sceneId, btnEl) { + try { + btnEl.disabled = true; + btnEl.textContent = 'Deleting...'; + const res = await fetch('/api/stash/scenes/destroy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scene_id: sceneId, + delete_file: true, + delete_generated: true + }) + }); + const data = await res.json(); + if (data.success) { + showToast('โœ“ Duplicate scene & video file deleted successfully!', 'success'); + const card = document.getElementById(`dupe-scene-card-${sceneId}`); + if (card) { + card.style.opacity = '0.35'; + card.innerHTML = `
๐Ÿ—‘๏ธ Deleted from Stash & Disk
`; + } + } else { + showToast(`Delete failed: ${data.error}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ—‘๏ธ Delete Copy'; + } + } catch (err) { + showToast(`Delete error: ${err.message}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ—‘๏ธ Delete Copy'; + } + } + + // Trigger Plugin Task + async function triggerPluginTask(pluginId, taskName) { + try { + showToast(`โšก Triggering ${pluginId}: ${taskName}...`, 'info'); + const res = await fetch('/api/stash/plugins/run-task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plugin_id: pluginId, task_name: taskName }) + }); + const data = await res.json(); + if (data.success) { + showToast(`โœ“ Stash task "${taskName}" launched!`, 'success'); + await pollStashJobs(); + } else { + showToast(`Task error: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Plugin execution error: ${err.message}`, 'error'); + } + } + + // Attach Plugin Triggers & Duplicate Scan Button + document.querySelectorAll('.btn-plugin-trigger').forEach(btn => { + btn.addEventListener('click', () => { + const pId = btn.getAttribute('data-plugin'); + const tName = btn.getAttribute('data-task'); + if (pId && tName) { + triggerPluginTask(pId, tName); + } + }); + }); + + const btnScanStashDupes = document.getElementById('btn-scan-stash-dupes'); + if (btnScanStashDupes) btnScanStashDupes.addEventListener('click', scanStashDuplicates); + // Attach Tab 5 Listeners const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs'); if (btnRefreshStashJobs) btnRefreshStashJobs.addEventListener('click', pollStashJobs); diff --git a/server.py b/server.py index 301035f..02fd5f0 100644 --- a/server.py +++ b/server.py @@ -198,11 +198,14 @@ def format_performer_slug(raw_name): 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): +def execute_stash_graphql(query_str, stash_url=None, variables=None, timeout=4): endpoint = normalize_stash_url(stash_url) + payload = {'query': query_str} + if variables: + payload['variables'] = variables req = urllib.request.Request( endpoint, - data=json.dumps({'query': query_str}).encode('utf-8'), + data=json.dumps(payload).encode('utf-8'), headers={'Content-Type': 'application/json'} ) with urllib.request.urlopen(req, timeout=timeout) as res: @@ -789,11 +792,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_performer_image() elif self.path.startswith('/api/image/proxy'): self.handle_image_proxy() - # Stash Status, Tasks & Performer Review APIs + # Stash Status, Tasks, Duplicates & Performer Review APIs elif self.path.startswith('/api/stash/status'): self.handle_stash_status() elif self.path.startswith('/api/stash/tasks/jobs'): self.handle_stash_tasks_jobs() + elif self.path.startswith('/api/stash/duplicates'): + self.handle_stash_duplicates() + elif self.path.startswith('/api/stash/plugins'): + self.handle_stash_plugins_list() elif self.path.startswith('/api/stash/performers/missing-images'): self.handle_stash_missing_images() # SMB API @@ -855,6 +862,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_stash_task_generate() elif self.path == '/api/stash/tasks/stop': self.handle_stash_task_stop() + elif self.path == '/api/stash/plugins/run-task': + self.handle_stash_run_plugin_task() + elif self.path == '/api/stash/scenes/destroy': + self.handle_stash_scene_destroy() elif self.path == '/api/stash/performers/find-images': self.handle_stash_find_images() elif self.path == '/api/stash/performers/commit-image': @@ -2148,6 +2159,161 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): except Exception as e: self.send_json_response(500, {"error": str(e)}) + # ==================== Stash Duplicate Checker & Plugin Integration Handlers ==================== + def handle_stash_duplicates(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0] + distance = int(params.get('distance', [0])[0]) + duration_diff = float(params.get('duration_diff', [5.0])[0]) + + query = ''' + query FindDuplicateScenes($distance: Int, $duration_diff: Float) { + findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) { + id + title + date + details + rating100 + organized + paths { + screenshot + preview + stream + } + files { + id + path + size + duration + video_codec + width + height + bit_rate + frame_rate + } + performers { + id + name + image_path + } + studio { + id + name + image_path + } + tags { + id + name + } + } + } + ''' + try: + res = execute_stash_graphql(query, stash_url, variables={"distance": distance, "duration_diff": duration_diff}, timeout=30) + raw_groups = res.get('data', {}).get('findDuplicateScenes') or [] + + duplicate_groups = [] + for group in raw_groups: + if isinstance(group, list) and len(group) > 1: + sorted_group = sorted(group, key=lambda s: ( + (s.get('files', [{}])[0].get('height') or 0), + (s.get('files', [{}])[0].get('bit_rate') or 0), + (s.get('files', [{}])[0].get('size') or 0) + ), reverse=True) + duplicate_groups.append(sorted_group) + + self.send_json_response(200, { + "groups": duplicate_groups, + "group_count": len(duplicate_groups), + "total_duplicate_scenes": sum(len(g) for g in duplicate_groups), + "distance": distance, + "duration_diff": duration_diff + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "groups": []}) + + def handle_stash_plugins_list(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0] + + query = ''' + { + plugins { + id + name + description + version + enabled + tasks { + name + description + } + } + } + ''' + try: + res = execute_stash_graphql(query, stash_url, timeout=10) + plugins = res.get('data', {}).get('plugins') or [] + self.send_json_response(200, {"plugins": plugins, "count": len(plugins)}) + except Exception as e: + self.send_json_response(500, {"error": str(e), "plugins": []}) + + def handle_stash_run_plugin_task(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')) + stash_url = payload.get('stash_url') or DEFAULT_STASH_URL + plugin_id = payload.get('plugin_id') + task_name = payload.get('task_name') + + if not plugin_id or not task_name: + self.send_json_response(400, {"error": "Missing plugin_id or task_name"}) + return + + mutation = ''' + mutation RunPluginTask($plugin_id: ID!, $task_name: String!) { + runPluginTask(plugin_id: $plugin_id, task_name: $task_name) + } + ''' + res = execute_stash_graphql(mutation, stash_url, variables={"plugin_id": plugin_id, "task_name": task_name}, timeout=15) + self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('runPluginTask')}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + def handle_stash_scene_destroy(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')) + stash_url = payload.get('stash_url') or DEFAULT_STASH_URL + scene_id = payload.get('scene_id') + delete_file = bool(payload.get('delete_file', True)) + delete_generated = bool(payload.get('delete_generated', True)) + + if not scene_id: + self.send_json_response(400, {"error": "Missing scene_id"}) + return + + mutation = ''' + mutation SceneDestroy($input: SceneDestroyInput!) { + sceneDestroy(input: $input) + } + ''' + variables = { + "input": { + "id": str(scene_id), + "delete_file": delete_file, + "delete_generated": delete_generated + } + } + res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) + self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('sceneDestroy')}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + # ==================== SMB Maintenance & Cleanup Handlers ==================== def handle_smb_scan_cleanup(self): parsed = urllib.parse.urlparse(self.path)