feat: Add Performer Merge Studio, Missing Metadata Radar, Tag Normalizer & Post-Move Pipeline Automation

This commit is contained in:
david
2026-08-18 10:33:57 -04:00
parent 1a0e7f619a
commit baf3a927a4
2 changed files with 837 additions and 0 deletions
+539
View File
@@ -1242,6 +1242,13 @@
<option value="first">First Listed Performer as Primary</option>
</select>
</div>
<div class="form-group">
<label for="stash-auto-pipeline">Post-Move Automation</label>
<select id="stash-auto-pipeline" class="form-control">
<option value="true" selected>⚡ Auto-Trigger Stash Scan & Identify on Relocation</option>
<option value="false">Manual Task Triggers Only</option>
</select>
</div>
</div>
</div>
@@ -2046,6 +2053,108 @@
</div>
</div>
<!-- ==================== SECTION A: PERFORMER MERGE & DEDUPLICATION STUDIO ==================== -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem; border-color: rgba(168, 85, 247, 0.4);">
<div class="table-header-bar" style="margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.75rem;">
<div>
<div class="table-title" style="color: var(--accent-purple);">
👥 Stash Performer Merge & Deduplication Studio
</div>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
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;">
<button class="btn btn-secondary" id="btn-scan-performer-dupes" style="font-size: 0.8rem;">
🔍 Find Performer Duplicates
</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>
</div>
</div>
<div id="performer-dupes-container">
<div class="empty-state">
<div class="empty-state-icon">👥</div>
<h3>No performer duplicate scan executed yet</h3>
<p>Click "Find Performer Duplicates" to search your entire Stash performer library for name variations.</p>
</div>
</div>
</div>
<!-- ==================== SECTION B: MISSING METADATA RADAR & AUTO-TAGGER ==================== -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem; border-color: rgba(245, 158, 11, 0.4);">
<div class="table-header-bar" style="margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.75rem;">
<div>
<div class="table-title" style="color: var(--accent-amber);">
📡 Stash Missing Metadata Radar & Batch Auto-Tagger
</div>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
Inspect scenes missing critical metadata (studios, performers, tags, or release dates) and trigger targeted auto-tagging.
</p>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-primary" id="btn-batch-autotag-missing" style="font-size: 0.8rem; background: linear-gradient(135deg, #d97706, #b45309);">
⚡ Auto-Tag Missing Metadata
</button>
</div>
</div>
<!-- Radar Type Selector -->
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap;">
<button class="btn btn-secondary btn-radar-filter active" data-type="studio" style="font-size: 0.75rem;">
🏢 Missing Studio
</button>
<button class="btn btn-secondary btn-radar-filter" data-type="performers" style="font-size: 0.75rem;">
👤 Missing Performers
</button>
<button class="btn btn-secondary btn-radar-filter" data-type="tags" style="font-size: 0.75rem;">
🏷️ Missing Tags
</button>
<button class="btn btn-secondary btn-radar-filter" data-type="date" style="font-size: 0.75rem;">
📅 Missing Date
</button>
</div>
<div id="missing-metadata-container">
<div class="empty-state">
<div class="empty-state-icon">📡</div>
<h3>Loading metadata radar...</h3>
</div>
</div>
</div>
<!-- ==================== SECTION C: TAG NORMALIZER & DEDUPLICATION ==================== -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem;">
<div class="table-header-bar" style="margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.75rem;">
<div>
<div class="table-title" style="color: var(--accent-cyan);">
🏷️ Stash Tag Normalizer & Deduplication Studio
</div>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
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;">
<button class="btn btn-secondary" id="btn-scan-tag-dupes" style="font-size: 0.8rem;">
🔍 Find Tag Duplicates
</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>
</div>
</div>
<div id="tag-dupes-container">
<div class="empty-state">
<div class="empty-state-icon">🏷️</div>
<h3>No tag duplicate scan executed yet</h3>
<p>Click "Find Tag Duplicates" to search your Stash tags for duplicates and aliases.</p>
</div>
</div>
</div>
</div>
</main>
@@ -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 = `
<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>
</div>
`;
}
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 = `<div style="color: var(--accent-rose); padding: 1rem;">Failed to scan performers: ${err.message}</div>`;
}
} 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 = `
<div class="empty-state">
<div class="empty-state-icon">✨</div>
<h3>No duplicate performer profiles found</h3>
<p>Your Stash performer library has 0 detected name collisions or unmerged aliases.</p>
</div>
`;
return;
}
container.innerHTML = clusters.map((cluster, cIdx) => {
const primary = cluster.primary;
const candidates = cluster.candidates;
const sourceIds = candidates.map(c => c.id);
return `
<div id="performer-cluster-card-${cIdx}" style="background: rgba(15, 23, 42, 0.85); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 1rem; margin-bottom: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-weight: 700; font-size: 0.85rem; color: var(--accent-purple);">
Cluster #${cIdx + 1}: "${escapeHtml(primary.name)}"
</span>
<span class="badge badge-purple" style="font-size: 0.68rem;">${cluster.count} Profiles</span>
</div>
<button class="btn btn-primary btn-merge-performer-cluster" data-cluster-idx="${cIdx}" style="font-size: 0.75rem; background: linear-gradient(135deg, #7c3aed, #9333ea);">
🔀 Merge Into Primary (${escapeHtml(primary.name)})
</button>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0.75rem;">
<!-- Primary Card -->
<div style="background: rgba(30, 41, 59, 0.7); border: 1px solid rgba(168, 85, 247, 0.5); border-radius: var(--radius-sm); padding: 0.75rem; display: flex; gap: 0.75rem; align-items: center;">
<div style="width: 55px; height: 70px; border-radius: var(--radius-sm); overflow: hidden; background: #000; flex-shrink: 0;">
${primary.image_path ? `<img src="${primary.image_path}" alt="" style="width: 100%; height: 100%; object-fit: cover;" onerror="this.style.display='none'" />` : ''}
</div>
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span style="font-weight: 700; color: #fff; font-size: 0.82rem;">${escapeHtml(primary.name)}</span>
<span class="badge badge-success" style="font-size: 0.62rem;">Primary Target</span>
</div>
<div style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
🎬 ${primary.scene_count || 0} scenes • ID: ${primary.id}
</div>
${primary.aliases ? `<div style="font-size: 0.68rem; color: var(--text-muted); margin-top: 0.2rem;">Aliases: ${escapeHtml(primary.aliases)}</div>` : ''}
</div>
</div>
<!-- Candidate Duplicates -->
${candidates.map(cand => `
<div style="background: rgba(30, 41, 59, 0.5); border: 1px solid rgba(244, 63, 94, 0.35); border-radius: var(--radius-sm); padding: 0.75rem; display: flex; gap: 0.75rem; align-items: center;">
<div style="width: 55px; height: 70px; border-radius: var(--radius-sm); overflow: hidden; background: #000; flex-shrink: 0;">
${cand.image_path ? `<img src="${cand.image_path}" alt="" style="width: 100%; height: 100%; object-fit: cover;" onerror="this.style.display='none'" />` : ''}
</div>
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; gap: 0.4rem;">
<span style="font-weight: 600; color: #fff; font-size: 0.82rem;">${escapeHtml(cand.name)}</span>
<span class="badge" style="background: rgba(244,63,94,0.15); color: #fda4af; font-size: 0.62rem;">Duplicate</span>
</div>
<div style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
🎬 ${cand.scene_count || 0} scenes • ID: ${cand.id}
</div>
${cand.aliases ? `<div style="font-size: 0.68rem; color: var(--text-muted); margin-top: 0.2rem;">Aliases: ${escapeHtml(cand.aliases)}</div>` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
}).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 = `<div style="padding: 1.5rem; text-align: center; color: var(--accent-green); font-size: 0.82rem;">✅ Successfully Merged into "${escapeHtml(destName)}"</div>`;
}
} 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 = `
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">
<div class="empty-state-icon">⏳</div>
<p>Querying Stash scenes missing ${type}...</p>
</div>
`;
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 = `<div style="color: var(--accent-rose); padding: 1rem;">Radar error: ${err.message}</div>`;
}
}
function renderMissingMetadataScenes(scenes, totalCount, type) {
const container = document.getElementById('missing-metadata-container');
if (!container) return;
if (!scenes || scenes.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">✨</div>
<h3>100% Complete!</h3>
<p>No scenes in your Stash library are currently missing ${type}.</p>
</div>
`;
return;
}
container.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<span style="font-size: 0.78rem; color: var(--accent-amber); font-weight: 600;">
Found ${totalCount.toLocaleString()} scenes missing ${type} (Showing latest sample)
</span>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.75rem;">
${scenes.map(s => {
const file = (s.files && s.files[0]) || {};
const performerNames = (s.performers || []).map(p => p.name).join(', ') || 'No performers';
return `
<div style="background: rgba(30, 41, 59, 0.6); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.75rem; display: flex; flex-direction: column; gap: 0.4rem;">
<div style="font-weight: 600; color: #fff; font-size: 0.8rem; line-height: 1.2;">
${escapeHtml(s.title || (file.path ? file.path.split('/').pop() : `Scene #${s.id}`))}
</div>
<div style="font-size: 0.7rem; color: var(--text-muted);">
<span>👤 ${escapeHtml(performerNames)}</span>
</div>
<div style="font-size: 0.68rem; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; background: rgba(0,0,0,0.25); padding: 0.35rem; border-radius: 4px;">
📁 ${escapeHtml(file.path || '')}
</div>
</div>
`;
}).join('')}
</div>
`;
}
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 = `
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">
<div class="empty-state-icon">⏳</div>
<p>Scanning Stash tag library for case/hyphen duplicates...</p>
</div>
`;
}
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 = `<div style="color: var(--accent-rose); padding: 1rem;">Failed to scan tags: ${err.message}</div>`;
}
} 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 = `
<div class="empty-state">
<div class="empty-state-icon">✨</div>
<h3>No duplicate tags found</h3>
<p>Your Stash tag taxonomy is clean and normalized.</p>
</div>
`;
return;
}
container.innerHTML = clusters.map((cluster, cIdx) => {
const primary = cluster.primary;
const candidates = cluster.candidates;
const sourceIds = candidates.map(c => c.id);
return `
<div id="tag-cluster-card-${cIdx}" style="background: rgba(15, 23, 42, 0.85); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-weight: 700; font-size: 0.85rem; color: var(--accent-cyan);">
🏷️ "${escapeHtml(primary.name)}"
</span>
<span class="badge badge-primary" style="font-size: 0.68rem;">${cluster.count} Tags</span>
</div>
<button class="btn btn-secondary btn-merge-tag-cluster" data-cluster-idx="${cIdx}" style="font-size: 0.75rem; color: var(--accent-cyan); border-color: rgba(6,182,212,0.4);">
🔀 Merge Into "${escapeHtml(primary.name)}"
</button>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem;">
<span class="badge badge-success" style="font-size: 0.72rem; padding: 0.3rem 0.6rem;">
${escapeHtml(primary.name)} (${primary.scene_count || 0} scenes) [Primary]
</span>
${candidates.map(cand => `
<span class="badge" style="font-size: 0.72rem; padding: 0.3rem 0.6rem; background: rgba(244,63,94,0.15); color: #fda4af; border-color: rgba(244,63,94,0.3);">
${escapeHtml(cand.name)} (${cand.scene_count || 0} scenes)
</span>
`).join('')}
</div>
</div>
`;
}).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 = `<div style="padding: 1rem; text-align: center; color: var(--accent-green); font-size: 0.8rem;">✅ Merged into "${escapeHtml(destName)}"</div>`;
}
} 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(); } },
+298
View File
@@ -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)