+
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)