feat: Add Performer Merge Studio, Missing Metadata Radar, Tag Normalizer & Post-Move Pipeline Automation
This commit is contained in:
@@ -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(); } },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user