feat: Add 1-Click Batch Merge All for Performer and Tag Duplicates

This commit is contained in:
david
2026-08-18 11:08:40 -04:00
parent b175a20af5
commit 45cb902647
2 changed files with 211 additions and 3 deletions
+117 -3
View File
@@ -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.
</p>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn btn-secondary" id="btn-scan-performer-dupes" style="font-size: 0.8rem;">
🔍 Find Performer Duplicates
</button>
<button class="btn btn-primary" id="btn-batch-merge-performers" style="font-size: 0.8rem; display: none; background: linear-gradient(135deg, #a855f7, #7e22ce);">
⚡ Batch Merge All (<span id="performer-batch-count">0</span>)
</button>
<span class="badge" id="performer-dupe-count-badge" style="background: rgba(168, 85, 247, 0.15); color: var(--accent-purple); border-color: rgba(168, 85, 247, 0.3); font-size: 0.75rem;">
0 duplicate clusters
</span>
@@ -2136,10 +2139,13 @@
Find duplicate tags with casing/dash/dot variations and merge them into canonical tags.
</p>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn btn-secondary" id="btn-scan-tag-dupes" style="font-size: 0.8rem;">
🔍 Find Tag Duplicates
</button>
<button class="btn btn-primary" id="btn-batch-merge-tags" style="font-size: 0.8rem; display: none; background: linear-gradient(135deg, #06b6d4, #0891b2);">
⚡ Batch Merge All (<span id="tag-batch-count">0</span>)
</button>
<span class="badge" id="tag-dupe-count-badge" style="background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); border-color: rgba(6, 182, 212, 0.3); font-size: 0.75rem;">
0 tag clusters
</span>
@@ -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 = `
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">
<div class="empty-state-icon">⏳</div>
<p>Scanning Stash performer library for name variations and duplicates...</p>
<p>Scanning Stash performer library for name variations...</p>
</div>
`;
}
@@ -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(); } },
+94
View File
@@ -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)