From 45cb902647ba8f2cb9b0558df703ee39ee703a58 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 18 Aug 2026 11:08:40 -0400 Subject: [PATCH] feat: Add 1-Click Batch Merge All for Performer and Tag Duplicates --- qbittorrent_performer_sorter.html | 120 +++++++++++++++++++++++++++++- server.py | 94 +++++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index b935ed6..1ba0454 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -2064,10 +2064,13 @@ Detect similarly-named performers (e.g. "cory.chase" vs "Cory Chase") and merge profiles while combining all scenes, tags, and images into the primary performer.

-
+
+ 0 duplicate clusters @@ -2136,10 +2139,13 @@ Find duplicate tags with casing/dash/dot variations and merge them into canonical tags.

-
+
+ 0 tag clusters @@ -7239,6 +7245,8 @@ Return ONLY a valid JSON object with an array named "results": async function scanPerformerDuplicates() { const btn = document.getElementById('btn-scan-performer-dupes'); + const batchBtn = document.getElementById('btn-batch-merge-performers'); + const batchCount = document.getElementById('performer-batch-count'); const container = document.getElementById('performer-dupes-container'); const badge = document.getElementById('performer-dupe-count-badge'); @@ -7248,7 +7256,7 @@ Return ONLY a valid JSON object with an array named "results": container.innerHTML = `
โณ
-

Scanning Stash performer library for name variations and duplicates...

+

Scanning Stash performer library for name variations...

`; } @@ -7260,6 +7268,15 @@ Return ONLY a valid JSON object with an array named "results": if (badge) badge.textContent = `${performerDuplicateClusters.length} duplicate clusters (${data.total_performers || 0} total performers)`; + if (batchBtn && batchCount) { + if (performerDuplicateClusters.length > 0) { + batchCount.textContent = performerDuplicateClusters.length; + batchBtn.style.display = 'inline-flex'; + } else { + batchBtn.style.display = 'none'; + } + } + renderPerformerDuplicates(performerDuplicateClusters); } catch (err) { showToast(`Performer scan error: ${err.message}`, 'error'); @@ -7392,9 +7409,51 @@ Return ONLY a valid JSON object with an array named "results": } } + async function executeBatchPerformerMerge() { + if (!performerDuplicateClusters || performerDuplicateClusters.length === 0) { + showToast('No performer duplicate clusters to merge', 'error'); + return; + } + + const totalClusters = performerDuplicateClusters.length; + if (!confirm(`Batch merge ALL ${totalClusters} duplicate performer clusters into their primary profiles?\n\nThis will transfer all linked scenes, tags, and gallery images to the primary performer profile for each cluster.`)) { + return; + } + + const btn = document.getElementById('btn-batch-merge-performers'); + try { + if (btn) { btn.disabled = true; btn.textContent = `Merging ${totalClusters} clusters...`; } + + const payloadClusters = performerDuplicateClusters.map(c => ({ + destination_id: c.primary.id, + source_ids: c.candidates.map(cand => cand.id) + })); + + const res = await fetch('/api/stash/performers/batch-merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clusters: payloadClusters }) + }); + const data = await res.json(); + if (data.success) { + showToast(`โœ“ Batch merged ${data.merged_count} performer clusters!`, 'success'); + await scanPerformerDuplicates(); + } else { + showToast(`Batch merge error: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Batch merge failed: ${err.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = `โšก Batch Merge All (${performerDuplicateClusters.length})`; } + } + } + const btnScanPerformerDupes = document.getElementById('btn-scan-performer-dupes'); if (btnScanPerformerDupes) btnScanPerformerDupes.addEventListener('click', scanPerformerDuplicates); + const btnBatchMergePerformers = document.getElementById('btn-batch-merge-performers'); + if (btnBatchMergePerformers) btnBatchMergePerformers.addEventListener('click', executeBatchPerformerMerge); + // ==================== SECTION B: MISSING METADATA RADAR ==================== let currentRadarType = 'studio'; @@ -7505,6 +7564,8 @@ Return ONLY a valid JSON object with an array named "results": async function scanTagDuplicates() { const btn = document.getElementById('btn-scan-tag-dupes'); + const batchBtn = document.getElementById('btn-batch-merge-tags'); + const batchCount = document.getElementById('tag-batch-count'); const container = document.getElementById('tag-dupes-container'); const badge = document.getElementById('tag-dupe-count-badge'); @@ -7526,6 +7587,15 @@ Return ONLY a valid JSON object with an array named "results": if (badge) badge.textContent = `${tagDuplicateClusters.length} tag clusters (${data.total_tags || 0} total tags)`; + if (batchBtn && batchCount) { + if (tagDuplicateClusters.length > 0) { + batchCount.textContent = tagDuplicateClusters.length; + batchBtn.style.display = 'inline-flex'; + } else { + batchBtn.style.display = 'none'; + } + } + renderTagDuplicates(tagDuplicateClusters); } catch (err) { showToast(`Tag scan error: ${err.message}`, 'error'); @@ -7631,9 +7701,51 @@ Return ONLY a valid JSON object with an array named "results": } } + async function executeBatchTagMerge() { + if (!tagDuplicateClusters || tagDuplicateClusters.length === 0) { + showToast('No tag duplicate clusters to merge', 'error'); + return; + } + + const totalClusters = tagDuplicateClusters.length; + if (!confirm(`Batch merge ALL ${totalClusters} duplicate tag clusters into their primary tags?`)) { + return; + } + + const btn = document.getElementById('btn-batch-merge-tags'); + try { + if (btn) { btn.disabled = true; btn.textContent = `Merging ${totalClusters} tags...`; } + + const payloadClusters = tagDuplicateClusters.map(c => ({ + destination_id: c.primary.id, + source_ids: c.candidates.map(cand => cand.id) + })); + + const res = await fetch('/api/stash/tags/batch-merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clusters: payloadClusters }) + }); + const data = await res.json(); + if (data.success) { + showToast(`โœ“ Batch merged ${data.merged_count} tag clusters!`, 'success'); + await scanTagDuplicates(); + } else { + showToast(`Batch merge error: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Batch merge failed: ${err.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = `โšก Batch Merge All (${tagDuplicateClusters.length})`; } + } + } + const btnScanTagDupes = document.getElementById('btn-scan-tag-dupes'); if (btnScanTagDupes) btnScanTagDupes.addEventListener('click', scanTagDuplicates); + const btnBatchMergeTags = document.getElementById('btn-batch-merge-tags'); + if (btnBatchMergeTags) btnBatchMergeTags.addEventListener('click', executeBatchTagMerge); + // ==================== SECTION D: TRANSCODE CORRELATOR & STORAGE SAVER ==================== let transcodeItems = []; let selectedTranscodeHashes = new Set(); @@ -8007,8 +8119,10 @@ Return ONLY a valid JSON object with an array named "results": { title: 'Stash: Start Library Scan', icon: '๐Ÿš€', action: () => { switchTab('stash-tasks'); executeStashScanTask(); } }, { title: 'Stash: Identify & Auto-Tag Scenes', icon: '๐Ÿ”', action: () => { switchTab('stash-tasks'); executeStashIdentifyTask(); } }, { title: 'Stash: Find Performer Duplicates & Merge', icon: '๐Ÿ‘ฅ', action: () => { switchTab('stash-tasks'); scanPerformerDuplicates(); } }, + { title: 'Stash: Batch Merge All Performer Duplicates', icon: 'โšก', action: () => { switchTab('stash-tasks'); executeBatchPerformerMerge(); } }, { title: 'Stash: Missing Metadata Radar', icon: '๐Ÿ“ก', action: () => { switchTab('stash-tasks'); loadMissingMetadataRadar(); } }, { title: 'Stash: Tag Normalizer & Deduplication', icon: '๐Ÿท๏ธ', action: () => { switchTab('stash-tasks'); scanTagDuplicates(); } }, + { title: 'Stash: Batch Merge All Tag Duplicates', icon: 'โšก', action: () => { switchTab('stash-tasks'); executeBatchTagMerge(); } }, { title: 'Stash: Correlate Transcodes & Replace Originals', icon: '๐Ÿ”€', action: () => { switchTab('stash-tasks'); scanStashTranscodes(); } }, { title: 'Stash: Clean Database & Generated Files', icon: '๐Ÿงน', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } }, { title: 'Stash: Generate Previews & Sprites', icon: 'โšก', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } }, diff --git a/server.py b/server.py index 9f8dab9..86f32a2 100644 --- a/server.py +++ b/server.py @@ -884,8 +884,12 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_stash_batch_commit_images() elif self.path == '/api/stash/performers/merge': self.handle_stash_performer_merge() + elif self.path == '/api/stash/performers/batch-merge': + self.handle_stash_performer_batch_merge() elif self.path == '/api/stash/tags/merge': self.handle_stash_tag_merge() + elif self.path == '/api/stash/tags/batch-merge': + self.handle_stash_tag_batch_merge() elif self.path == '/api/stash/transcodes/replace': self.handle_stash_transcode_replace() elif self.path == '/api/stash/transcodes/batch-replace': @@ -2435,6 +2439,51 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): except Exception as e: self.send_json_response(500, {"error": str(e)}) + def handle_stash_performer_batch_merge(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 + clusters = payload.get('clusters', []) + + if not clusters: + self.send_json_response(400, {"error": "No clusters provided for batch merge"}) + return + + merged_count = 0 + errors = [] + mutation = ''' + mutation PerformerMerge($input: PerformerMergeInput!) { + performerMerge(input: $input) { + id + name + } + } + ''' + for c in clusters: + source_ids = [str(sid) for sid in c.get('source_ids', [])] + dest_id = str(c.get('destination_id')) + if not source_ids or not dest_id: + continue + try: + res = execute_stash_graphql(mutation, stash_url, variables={"input": {"source": source_ids, "destination": dest_id}}, timeout=20) + if res.get('data', {}).get('performerMerge'): + merged_count += 1 + elif res.get('errors'): + errors.append({"destination_id": dest_id, "error": str(res.get('errors'))}) + except Exception as ex: + errors.append({"destination_id": dest_id, "error": str(ex)}) + + self.send_json_response(200, { + "success": True, + "merged_count": merged_count, + "total_requested": len(clusters), + "errors": errors + }) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + # ==================== Missing Metadata Radar Handlers ==================== def handle_stash_missing_metadata(self): parsed = urllib.parse.urlparse(self.path) @@ -2618,6 +2667,51 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): except Exception as e: self.send_json_response(500, {"error": str(e)}) + def handle_stash_tag_batch_merge(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 + clusters = payload.get('clusters', []) + + if not clusters: + self.send_json_response(400, {"error": "No clusters provided for batch merge"}) + return + + merged_count = 0 + errors = [] + mutation = ''' + mutation TagsMerge($input: TagsMergeInput!) { + tagsMerge(input: $input) { + id + name + } + } + ''' + for c in clusters: + source_ids = [str(sid) for sid in c.get('source_ids', [])] + dest_id = str(c.get('destination_id')) + if not source_ids or not dest_id: + continue + try: + res = execute_stash_graphql(mutation, stash_url, variables={"input": {"source": source_ids, "destination": dest_id}}, timeout=20) + if res.get('data', {}).get('tagsMerge'): + merged_count += 1 + elif res.get('errors'): + errors.append({"destination_id": dest_id, "error": str(res.get('errors'))}) + except Exception as ex: + errors.append({"destination_id": dest_id, "error": str(ex)}) + + self.send_json_response(200, { + "success": True, + "merged_count": merged_count, + "total_requested": len(clusters), + "errors": errors + }) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + # ==================== Stash Transcode Correlator & Storage Saver Handlers ==================== def handle_stash_transcode_scan(self): parsed = urllib.parse.urlparse(self.path)