From baf3a927a484962c0196ad1ca1447f586ac87248 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 18 Aug 2026 10:33:57 -0400 Subject: [PATCH] feat: Add Performer Merge Studio, Missing Metadata Radar, Tag Normalizer & Post-Move Pipeline Automation --- qbittorrent_performer_sorter.html | 539 ++++++++++++++++++++++++++++++ server.py | 298 +++++++++++++++++ 2 files changed, 837 insertions(+) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index 491a6cc..cebeff2 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -1242,6 +1242,13 @@ +
+ + +
@@ -2046,6 +2053,108 @@ + +
+
+
+
+ ๐Ÿ‘ฅ Stash Performer Merge & Deduplication Studio +
+

+ 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 + +
+
+ +
+
+
๐Ÿ‘ฅ
+

No performer duplicate scan executed yet

+

Click "Find Performer Duplicates" to search your entire Stash performer library for name variations.

+
+
+
+ + +
+
+
+
+ ๐Ÿ“ก Stash Missing Metadata Radar & Batch Auto-Tagger +
+

+ Inspect scenes missing critical metadata (studios, performers, tags, or release dates) and trigger targeted auto-tagging. +

+
+
+ +
+
+ + +
+ + + + +
+ +
+
+
๐Ÿ“ก
+

Loading metadata radar...

+
+
+
+ + +
+
+
+
+ ๐Ÿท๏ธ Stash Tag Normalizer & Deduplication Studio +
+

+ Find duplicate tags with casing/dash/dot variations and merge them into canonical tags. +

+
+
+ + + 0 tag clusters + +
+
+ +
+
+
๐Ÿท๏ธ
+

No tag duplicate scan executed yet

+

Click "Find Tag Duplicates" to search your Stash tags for duplicates and aliases.

+
+
+
+ @@ -3890,6 +3999,7 @@ Return ONLY valid JSON matching: saveItemToDb(torrent, 'qb'); renderRow(torrent.hash); updateStats(); + triggerPostMoveAutomation(torrent.targetPath); } btnBatchRelocate.addEventListener('click', async () => { @@ -4755,6 +4865,7 @@ Return ONLY a valid JSON object with an array named "results": saveItemToDb(fileItem, 'smb'); renderSmbRow(fileItem.path); updateSmbStats(); + triggerPostMoveAutomation(fileItem.targetPath); } btnSmbBatchRelocate.addEventListener('click', async () => { @@ -7036,6 +7147,431 @@ Return ONLY a valid JSON object with an array named "results": const btnScanStashDupes = document.getElementById('btn-scan-stash-dupes'); if (btnScanStashDupes) btnScanStashDupes.addEventListener('click', scanStashDuplicates); + // ==================== SECTION A: PERFORMER MERGE STUDIO ==================== + let performerDuplicateClusters = []; + + async function scanPerformerDuplicates() { + const btn = document.getElementById('btn-scan-performer-dupes'); + const container = document.getElementById('performer-dupes-container'); + const badge = document.getElementById('performer-dupe-count-badge'); + + try { + if (btn) { btn.disabled = true; btn.textContent = 'Searching Performers...'; } + if (container) { + container.innerHTML = ` +
+
โณ
+

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

+
+ `; + } + + const res = await fetch('/api/stash/performers/duplicates'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + performerDuplicateClusters = data.clusters || []; + + if (badge) badge.textContent = `${performerDuplicateClusters.length} duplicate clusters (${data.total_performers || 0} total performers)`; + + renderPerformerDuplicates(performerDuplicateClusters); + } catch (err) { + showToast(`Performer scan error: ${err.message}`, 'error'); + if (container) { + container.innerHTML = `
Failed to scan performers: ${err.message}
`; + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = '๐Ÿ” Find Performer Duplicates'; } + } + } + + function renderPerformerDuplicates(clusters) { + const container = document.getElementById('performer-dupes-container'); + if (!container) return; + + if (!clusters || clusters.length === 0) { + container.innerHTML = ` +
+
โœจ
+

No duplicate performer profiles found

+

Your Stash performer library has 0 detected name collisions or unmerged aliases.

+
+ `; + return; + } + + container.innerHTML = clusters.map((cluster, cIdx) => { + const primary = cluster.primary; + const candidates = cluster.candidates; + const sourceIds = candidates.map(c => c.id); + + return ` +
+
+
+ + Cluster #${cIdx + 1}: "${escapeHtml(primary.name)}" + + ${cluster.count} Profiles +
+ +
+ +
+ +
+
+ ${primary.image_path ? `` : ''} +
+
+
+ ${escapeHtml(primary.name)} + Primary Target +
+
+ ๐ŸŽฌ ${primary.scene_count || 0} scenes โ€ข ID: ${primary.id} +
+ ${primary.aliases ? `
Aliases: ${escapeHtml(primary.aliases)}
` : ''} +
+
+ + + ${candidates.map(cand => ` +
+
+ ${cand.image_path ? `` : ''} +
+
+
+ ${escapeHtml(cand.name)} + Duplicate +
+
+ ๐ŸŽฌ ${cand.scene_count || 0} scenes โ€ข ID: ${cand.id} +
+ ${cand.aliases ? `
Aliases: ${escapeHtml(cand.aliases)}
` : ''} +
+
+ `).join('')} +
+
+ `; + }).join(''); + + container.querySelectorAll('.btn-merge-performer-cluster').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = parseInt(btn.getAttribute('data-cluster-idx'), 10); + const cluster = performerDuplicateClusters[idx]; + if (!cluster) return; + + const sourceIds = cluster.candidates.map(c => c.id); + const destId = cluster.primary.id; + const destName = cluster.primary.name; + + if (confirm(`Merge ${sourceIds.length} duplicate performer profile(s) into "${destName}" (ID: ${destId})?\nAll linked scenes, tags, and photos will be transferred.`)) { + await executePerformerMerge(sourceIds, destId, destName, idx, btn); + } + }); + }); + } + + async function executePerformerMerge(sourceIds, destId, destName, cardIdx, btnEl) { + try { + btnEl.disabled = true; + btnEl.textContent = 'Merging...'; + const res = await fetch('/api/stash/performers/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ids: sourceIds, destination_id: destId }) + }); + const data = await res.json(); + if (data.success) { + showToast(`โœ“ Merged duplicate performers into "${destName}"!`, 'success'); + const card = document.getElementById(`performer-cluster-card-${cardIdx}`); + if (card) { + card.style.opacity = '0.35'; + card.innerHTML = `
โœ… Successfully Merged into "${escapeHtml(destName)}"
`; + } + } else { + showToast(`Merge failed: ${data.error}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ”€ Merge Into Primary'; + } + } catch (err) { + showToast(`Merge error: ${err.message}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ”€ Merge Into Primary'; + } + } + + const btnScanPerformerDupes = document.getElementById('btn-scan-performer-dupes'); + if (btnScanPerformerDupes) btnScanPerformerDupes.addEventListener('click', scanPerformerDuplicates); + + // ==================== SECTION B: MISSING METADATA RADAR ==================== + let currentRadarType = 'studio'; + + async function loadMissingMetadataRadar(type = 'studio') { + currentRadarType = type; + const container = document.getElementById('missing-metadata-container'); + if (!container) return; + + container.innerHTML = ` +
+
โณ
+

Querying Stash scenes missing ${type}...

+
+ `; + + try { + const res = await fetch(`/api/stash/scenes/missing-metadata?type=${type}&per_page=12`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + renderMissingMetadataScenes(data.scenes || [], data.count || 0, type); + } catch (err) { + showToast(`Metadata radar error: ${err.message}`, 'error'); + container.innerHTML = `
Radar error: ${err.message}
`; + } + } + + function renderMissingMetadataScenes(scenes, totalCount, type) { + const container = document.getElementById('missing-metadata-container'); + if (!container) return; + + if (!scenes || scenes.length === 0) { + container.innerHTML = ` +
+
โœจ
+

100% Complete!

+

No scenes in your Stash library are currently missing ${type}.

+
+ `; + return; + } + + container.innerHTML = ` +
+ + Found ${totalCount.toLocaleString()} scenes missing ${type} (Showing latest sample) + +
+
+ ${scenes.map(s => { + const file = (s.files && s.files[0]) || {}; + const performerNames = (s.performers || []).map(p => p.name).join(', ') || 'No performers'; + return ` +
+
+ ${escapeHtml(s.title || (file.path ? file.path.split('/').pop() : `Scene #${s.id}`))} +
+
+ ๐Ÿ‘ค ${escapeHtml(performerNames)} +
+
+ ๐Ÿ“ ${escapeHtml(file.path || '')} +
+
+ `; + }).join('')} +
+ `; + } + + document.querySelectorAll('.btn-radar-filter').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.btn-radar-filter').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const type = btn.getAttribute('data-type'); + loadMissingMetadataRadar(type); + }); + }); + + const btnBatchAutotagMissing = document.getElementById('btn-batch-autotag-missing'); + if (btnBatchAutotagMissing) { + btnBatchAutotagMissing.addEventListener('click', async () => { + try { + btnBatchAutotagMissing.disabled = true; + btnBatchAutotagMissing.textContent = 'Launching Auto-Tagger...'; + const res = await fetch('/api/stash/scenes/batch-autotag', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ performers: true, studios: true, tags: true, paths: ['/isolation/videos'] }) + }); + const data = await res.json(); + if (data.success) { + showToast('โšก Stash Auto-Tagger launched on missing metadata scenes!', 'success'); + await pollStashJobs(); + } else { + showToast(`Auto-tag launch failed: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Auto-tag error: ${err.message}`, 'error'); + } finally { + btnBatchAutotagMissing.disabled = false; + btnBatchAutotagMissing.textContent = 'โšก Auto-Tag Missing Metadata'; + } + }); + } + + // ==================== SECTION C: TAG NORMALIZER ==================== + let tagDuplicateClusters = []; + + async function scanTagDuplicates() { + const btn = document.getElementById('btn-scan-tag-dupes'); + const container = document.getElementById('tag-dupes-container'); + const badge = document.getElementById('tag-dupe-count-badge'); + + try { + if (btn) { btn.disabled = true; btn.textContent = 'Searching Tags...'; } + if (container) { + container.innerHTML = ` +
+
โณ
+

Scanning Stash tag library for case/hyphen duplicates...

+
+ `; + } + + const res = await fetch('/api/stash/tags/duplicates'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + tagDuplicateClusters = data.clusters || []; + + if (badge) badge.textContent = `${tagDuplicateClusters.length} tag clusters (${data.total_tags || 0} total tags)`; + + renderTagDuplicates(tagDuplicateClusters); + } catch (err) { + showToast(`Tag scan error: ${err.message}`, 'error'); + if (container) { + container.innerHTML = `
Failed to scan tags: ${err.message}
`; + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = '๐Ÿ” Find Tag Duplicates'; } + } + } + + function renderTagDuplicates(clusters) { + const container = document.getElementById('tag-dupes-container'); + if (!container) return; + + if (!clusters || clusters.length === 0) { + container.innerHTML = ` +
+
โœจ
+

No duplicate tags found

+

Your Stash tag taxonomy is clean and normalized.

+
+ `; + return; + } + + container.innerHTML = clusters.map((cluster, cIdx) => { + const primary = cluster.primary; + const candidates = cluster.candidates; + const sourceIds = candidates.map(c => c.id); + + return ` +
+
+
+ + ๐Ÿท๏ธ "${escapeHtml(primary.name)}" + + ${cluster.count} Tags +
+ +
+ +
+ + ${escapeHtml(primary.name)} (${primary.scene_count || 0} scenes) [Primary] + + ${candidates.map(cand => ` + + ${escapeHtml(cand.name)} (${cand.scene_count || 0} scenes) + + `).join('')} +
+
+ `; + }).join(''); + + container.querySelectorAll('.btn-merge-tag-cluster').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = parseInt(btn.getAttribute('data-cluster-idx'), 10); + const cluster = tagDuplicateClusters[idx]; + if (!cluster) return; + + const sourceIds = cluster.candidates.map(c => c.id); + const destId = cluster.primary.id; + const destName = cluster.primary.name; + + if (confirm(`Merge duplicate tag(s) into "${destName}" (ID: ${destId})?`)) { + await executeTagMerge(sourceIds, destId, destName, idx, btn); + } + }); + }); + } + + async function executeTagMerge(sourceIds, destId, destName, cardIdx, btnEl) { + try { + btnEl.disabled = true; + btnEl.textContent = 'Merging...'; + const res = await fetch('/api/stash/tags/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_ids: sourceIds, destination_id: destId }) + }); + const data = await res.json(); + if (data.success) { + showToast(`โœ“ Merged tags into "${destName}"!`, 'success'); + const card = document.getElementById(`tag-cluster-card-${cardIdx}`); + if (card) { + card.style.opacity = '0.35'; + card.innerHTML = `
โœ… Merged into "${escapeHtml(destName)}"
`; + } + } else { + showToast(`Tag merge failed: ${data.error}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ”€ Merge'; + } + } catch (err) { + showToast(`Tag merge error: ${err.message}`, 'error'); + btnEl.disabled = false; + btnEl.textContent = '๐Ÿ”€ Merge'; + } + } + + const btnScanTagDupes = document.getElementById('btn-scan-tag-dupes'); + if (btnScanTagDupes) btnScanTagDupes.addEventListener('click', scanTagDuplicates); + + // ==================== POST-MOVE PIPELINE AUTOMATION ==================== + async function triggerPostMoveAutomation(targetDir) { + const autoPipeline = document.getElementById('stash-auto-pipeline')?.value; + if (autoPipeline !== 'true') return; + + try { + const path = targetDir ? [targetDir] : ['/isolation/videos']; + console.log('โšก Triggering post-move automation pipeline for:', path); + // Step 1: Scan + await fetch('/api/stash/tasks/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: path, scanGenerateCovers: true, scanGeneratePhashes: true }) + }); + // Step 2: AutoTag + await fetch('/api/stash/tasks/identify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'autotag', paths: path, performers: true, studios: true, tags: true }) + }); + } catch (e) { + console.warn('Silent post-move pipeline status:', e.message); + } + } + // Attach Tab 5 Listeners const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs'); if (btnRefreshStashJobs) btnRefreshStashJobs.addEventListener('click', pollStashJobs); @@ -7074,6 +7610,9 @@ Return ONLY a valid JSON object with an array named "results": { title: 'Switch to Stash Tasks & Tool Control', icon: 'โšก', action: () => switchTab('stash-tasks') }, { 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: Missing Metadata Radar', icon: '๐Ÿ“ก', action: () => { switchTab('stash-tasks'); loadMissingMetadataRadar(); } }, + { title: 'Stash: Tag Normalizer & Deduplication', icon: '๐Ÿท๏ธ', action: () => { switchTab('stash-tasks'); scanTagDuplicates(); } }, { title: 'Stash: Clean Database & Generated Files', icon: '๐Ÿงน', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } }, { title: 'Stash: Generate Previews & Sprites', icon: 'โšก', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } }, { title: 'Scan SMB Files (/videodownloader)', icon: '๐Ÿ”', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } }, diff --git a/server.py b/server.py index 02fd5f0..6bae6e1 100644 --- a/server.py +++ b/server.py @@ -801,8 +801,14 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_stash_duplicates() elif self.path.startswith('/api/stash/plugins'): self.handle_stash_plugins_list() + elif self.path.startswith('/api/stash/performers/duplicates'): + self.handle_stash_performer_duplicates() elif self.path.startswith('/api/stash/performers/missing-images'): self.handle_stash_missing_images() + elif self.path.startswith('/api/stash/scenes/missing-metadata'): + self.handle_stash_missing_metadata() + elif self.path.startswith('/api/stash/tags/duplicates'): + self.handle_stash_tag_duplicates() # SMB API elif self.path.startswith('/api/smb/status'): self.handle_smb_status() @@ -866,12 +872,18 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_stash_run_plugin_task() elif self.path == '/api/stash/scenes/destroy': self.handle_stash_scene_destroy() + elif self.path == '/api/stash/scenes/batch-autotag': + self.handle_stash_batch_autotag() elif self.path == '/api/stash/performers/find-images': self.handle_stash_find_images() elif self.path == '/api/stash/performers/commit-image': self.handle_stash_commit_image() elif self.path == '/api/stash/performers/batch-commit-images': self.handle_stash_batch_commit_images() + elif self.path == '/api/stash/performers/merge': + self.handle_stash_performer_merge() + elif self.path == '/api/stash/tags/merge': + self.handle_stash_tag_merge() # SMB API Moves, Deletes & Cleanups elif self.path == '/api/smb/move': self.handle_smb_move() @@ -2314,6 +2326,292 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): except Exception as e: self.send_json_response(500, {"error": str(e)}) + # ==================== Performer Deduplication & Merge Handlers ==================== + def handle_stash_performer_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] + + query = ''' + { + findPerformers(filter: { per_page: -1 }) { + count + performers { + id + name + gender + scene_count + image_path + alias_list + stash_ids { + endpoint + stash_id + } + } + } + } + ''' + try: + res = execute_stash_graphql(query, stash_url, timeout=30) + performers = res.get('data', {}).get('findPerformers', {}).get('performers') or [] + + # Group by normalized clean name + clusters = {} + for p in performers: + raw_name = p.get('name') or '' + # Normalize: remove dots, underscores, dashes, trailing roman numerals/numbers + norm = re.sub(r'\s*\([ivx0-9]+\)\s*$', '', raw_name, flags=re.IGNORECASE) + norm = re.sub(r'[\._\-]+', ' ', norm).strip().lower() + norm_compact = re.sub(r'[^a-z0-9]', '', norm) + if not norm_compact: + continue + + if norm_compact not in clusters: + clusters[norm_compact] = [] + clusters[norm_compact].append(p) + + # Filter only clusters with > 1 performer + duplicate_clusters = [] + for k, group in clusters.items(): + if len(group) > 1: + # Sort so performer with most scenes / image is primary + sorted_group = sorted(group, key=lambda x: ( + bool(x.get('image_path')), + x.get('scene_count') or 0, + len(x.get('stash_ids') or []) + ), reverse=True) + duplicate_clusters.append({ + "normalized_name": k, + "primary": sorted_group[0], + "candidates": sorted_group[1:], + "all": sorted_group, + "count": len(sorted_group) + }) + + self.send_json_response(200, { + "clusters": duplicate_clusters, + "cluster_count": len(duplicate_clusters), + "total_performers": len(performers) + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "clusters": []}) + + def handle_stash_performer_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 + source_ids = [str(sid) for sid in payload.get('source_ids', [])] + dest_id = str(payload.get('destination_id')) + + if not source_ids or not dest_id: + self.send_json_response(400, {"error": "Missing source_ids or destination_id"}) + return + + mutation = ''' + mutation PerformerMerge($input: PerformerMergeInput!) { + performerMerge(input: $input) { + id + name + scene_count + } + } + ''' + variables = { + "input": { + "source": source_ids, + "destination": dest_id + } + } + res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20) + self.send_json_response(200, {"success": True, "merged_performer": res.get('data', {}).get('performerMerge')}) + 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) + params = urllib.parse.parse_qs(parsed.query) + stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0] + missing_type = params.get('type', ['studio'])[0] + page = int(params.get('page', [1])[0]) + per_page = int(params.get('per_page', [24])[0]) + + query = ''' + query FindMissingMetadataScenes($scene_filter: SceneFilterType, $filter: FindFilterType) { + findScenes(scene_filter: $scene_filter, filter: $filter) { + count + scenes { + id + title + date + rating100 + paths { + screenshot + preview + } + files { + id + path + size + duration + video_codec + width + height + } + performers { + id + name + image_path + } + studio { + id + name + } + tags { + id + name + } + } + } + } + ''' + try: + variables = { + "scene_filter": {"is_missing": missing_type}, + "filter": {"per_page": per_page, "page": page} + } + res = execute_stash_graphql(query, stash_url, variables=variables, timeout=20) + data = res.get('data', {}).get('findScenes') or {} + self.send_json_response(200, { + "type": missing_type, + "count": data.get('count', 0), + "scenes": data.get('scenes', []), + "page": page, + "per_page": per_page + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "scenes": [], "count": 0}) + + def handle_stash_batch_autotag(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 + paths = payload.get('paths', []) + tag_performers = bool(payload.get('performers', True)) + tag_studios = bool(payload.get('studios', True)) + tag_tags = bool(payload.get('tags', True)) + + mutation = ''' + mutation AutoTagScenes($input: AutoTagMetadataInput!) { + metadataAutoTag(input: $input) + } + ''' + variables = { + "input": { + "paths": paths if paths else [], + "performers": tag_performers, + "studios": tag_studios, + "tags": tag_tags + } + } + res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20) + self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + # ==================== Tag Normalizer & Deduplication Handlers ==================== + def handle_stash_tag_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] + + query = ''' + { + findTags(filter: { per_page: -1 }) { + count + tags { + id + name + scene_count + image_path + aliases + } + } + } + ''' + try: + res = execute_stash_graphql(query, stash_url, timeout=20) + tags = res.get('data', {}).get('findTags', {}).get('tags') or [] + + clusters = {} + for t in tags: + raw_name = t.get('name') or '' + norm = re.sub(r'[\._\-]+', ' ', raw_name).strip().lower() + norm_compact = re.sub(r'[^a-z0-9]', '', norm) + if not norm_compact: + continue + + if norm_compact not in clusters: + clusters[norm_compact] = [] + clusters[norm_compact].append(t) + + duplicate_clusters = [] + for k, group in clusters.items(): + if len(group) > 1: + sorted_group = sorted(group, key=lambda x: (x.get('scene_count') or 0), reverse=True) + duplicate_clusters.append({ + "normalized_name": k, + "primary": sorted_group[0], + "candidates": sorted_group[1:], + "all": sorted_group, + "count": len(sorted_group) + }) + + self.send_json_response(200, { + "clusters": duplicate_clusters, + "cluster_count": len(duplicate_clusters), + "total_tags": len(tags) + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "clusters": []}) + + def handle_stash_tag_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 + source_ids = [str(sid) for sid in payload.get('source_ids', [])] + dest_id = str(payload.get('destination_id')) + + if not source_ids or not dest_id: + self.send_json_response(400, {"error": "Missing source_ids or destination_id"}) + return + + mutation = ''' + mutation TagsMerge($input: TagsMergeInput!) { + tagsMerge(input: $input) { + id + name + scene_count + } + } + ''' + variables = { + "input": { + "source": source_ids, + "destination": dest_id + } + } + res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20) + self.send_json_response(200, {"success": True, "merged_tag": res.get('data', {}).get('tagsMerge')}) + 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)