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 @@
First Listed Performer as Primary
+
+ Post-Move Automation
+
+ โก Auto-Trigger Stash Scan & Identify on Relocation
+ Manual Task Triggers Only
+
+
@@ -2046,6 +2053,108 @@
+
+
+
+
+
+
+
+
+
+
+ ๐ข Missing Studio
+
+
+ ๐ค Missing Performers
+
+
+ ๐ท๏ธ Missing Tags
+
+
+ ๐
Missing Date
+
+
+
+
+
+
+
+
+
+
+
+
+
๐ท๏ธ
+
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 `
+
+ `;
+ }).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
+
+
+ ๐ Merge Into "${escapeHtml(primary.name)}"
+
+
+
+
+
+ ${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)