feat: Add Stash Performer Image Review tab with AI/web candidate photo finder, interactive visual review grid, and batch commit to Stash

This commit is contained in:
david
2026-08-17 10:46:46 -04:00
parent f553c4d02e
commit a273804dde
2 changed files with 995 additions and 13 deletions
+724 -11
View File
@@ -776,6 +776,85 @@
flex-direction: column;
gap: 0.75rem;
}
/* Stash Performer Image Review Grid & Card Styles */
.performer-review-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 1.25rem;
margin-top: 0.5rem;
}
.performer-review-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
transition: all var(--transition-fast);
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
}
.performer-review-card:hover {
border-color: rgba(255, 255, 255, 0.18);
}
.performer-review-card.selected {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px var(--accent-cyan), 0 8px 24px rgba(6,182,212,0.18);
}
.performer-review-card.committed {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.04);
}
.candidate-images-strip {
display: flex;
gap: 0.6rem;
overflow-x: auto;
padding: 0.35rem 0.2rem 0.6rem 0.2rem;
}
.candidate-image-item {
position: relative;
width: 82px;
height: 112px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 2px solid var(--border-color);
cursor: pointer;
flex-shrink: 0;
background: #000;
transition: all 0.15s ease;
}
.candidate-image-item:hover {
border-color: var(--accent-cyan);
transform: translateY(-2px);
}
.candidate-image-item.active {
border-color: var(--accent-green);
box-shadow: 0 0 12px rgba(34, 197, 94, 0.6);
transform: translateY(-2px);
}
.candidate-image-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.candidate-active-badge {
position: absolute;
top: 3px;
right: 3px;
background: var(--accent-green);
color: #000;
font-size: 0.65rem;
font-weight: 800;
border-radius: 999px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.6);
}
.btn-preview-play {
display: inline-flex;
align-items: center;
@@ -954,6 +1033,11 @@
<span>SMB Share Sorter (videodownloader → /isolation/videos)</span>
<span class="tab-badge" id="smb-tab-badge">0</span>
</button>
<button class="tab-btn" id="tab-btn-stash-images">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>Stash Performer Image Review</span>
<span class="tab-badge" id="stash-images-tab-badge" style="background: rgba(244, 63, 94, 0.2); color: #fda4af; border-color: rgba(244, 63, 94, 0.4);">0</span>
</button>
</div>
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
@@ -1363,6 +1447,100 @@
</div>
</div>
</div>
<!-- ==================== TAB 3: Stash Performer Image Review ==================== -->
<div class="tab-panel" id="tab-panel-stash-images">
<!-- Stash Images Stats Row -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Performers Missing Photos</span>
<span class="stat-value" id="stash-img-stat-missing" style="color: var(--accent-rose);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Candidate Photos Found</span>
<span class="stat-value" id="stash-img-stat-found" style="color: var(--accent-cyan);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Selected To Commit</span>
<span class="stat-value" id="stash-img-stat-selected" style="color: var(--accent-amber);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Attached to Stash</span>
<span class="stat-value" id="stash-img-stat-committed" style="color: var(--accent-green);">0</span>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<div class="btn-group">
<button class="btn btn-primary" id="btn-scan-stash-images">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Scan Stash Missing Images
</button>
<button class="btn btn-secondary" id="btn-stash-find-images-selected" disabled title="Search online image candidates for selected performers">
⚡ Find Images (Selected)
</button>
<button class="btn btn-secondary" id="btn-stash-find-images-all" disabled title="Search online image candidates for all listed performers">
⚡ Find Images (All Missing)
</button>
<button class="btn btn-secondary" id="btn-stash-stop-images" style="display: none; background: #7f1d1d; border-color: #991b1b; color: #fff;">
⛔ Stop Search
</button>
<button class="btn btn-success" id="btn-commit-stash-images" disabled title="Upload and save selected images into Stash">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
Commit Selected Images to Stash
</button>
</div>
<div style="display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;">
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; user-select: none;">
<input type="checkbox" id="toggle-stash-female-only" checked />
<span>Female Only</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; user-select: none;">
<input type="checkbox" id="toggle-stash-has-scenes" checked />
<span>With Scenes Only (&gt;= 1)</span>
</label>
<div class="search-filter" style="margin-left: auto;">
<input type="text" class="search-input" id="stash-images-search-input" placeholder="Filter performers by name..." />
</div>
</div>
</div>
<div class="progress-bar-container" id="stash-img-progress-container">
<div class="progress-bar-fill" id="stash-img-progress-fill"></div>
</div>
<!-- Performers Review Cards Grid -->
<div class="table-container" style="padding: 1.25rem;">
<div class="table-header-bar" style="margin-bottom: 1rem;">
<div class="table-title">
Stash Performers Missing Photos & Candidate Review
</div>
<div style="display: flex; gap: 0.75rem; align-items: center;">
<label style="font-size: 0.75rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="th-stash-img-select-all" />
<span>Select All</span>
</label>
<span style="font-size: 0.75rem; color: var(--text-muted);" id="stash-img-selection-count">0 selected</span>
</div>
</div>
<div id="stash-performer-cards-container" class="performer-review-grid">
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-state-icon">🖼️</div>
<h3>No performers scanned yet</h3>
<p>Click "Scan Stash Missing Images" to detect performers in your Stash instance without profile pictures.</p>
</div>
</div>
</div>
</div>
</main>
<!-- File Modal -->
@@ -1707,24 +1885,27 @@
]
};
// DOM Elements - Tab 3 Stash Images
const tabBtnStashImages = document.getElementById('tab-btn-stash-images');
const tabPanelStashImages = document.getElementById('tab-panel-stash-images');
const stashImagesTabBadge = document.getElementById('stash-images-tab-badge');
// Tab Switching Logic
function switchTab(tabName) {
if (tabName === 'smb') {
tabBtnSmb.classList.add('active');
tabBtnQb.classList.remove('active');
tabPanelSmb.classList.add('active');
tabPanelQb.classList.remove('active');
} else {
tabBtnQb.classList.add('active');
tabBtnSmb.classList.remove('active');
tabPanelQb.classList.add('active');
tabPanelSmb.classList.remove('active');
}
tabBtnQb.classList.toggle('active', tabName === 'qb');
tabBtnSmb.classList.toggle('active', tabName === 'smb');
if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images');
tabPanelQb.classList.toggle('active', tabName === 'qb');
tabPanelSmb.classList.toggle('active', tabName === 'smb');
if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images');
localStorage.setItem('qb_active_tab', tabName);
}
tabBtnQb.addEventListener('click', () => switchTab('qb'));
tabBtnSmb.addEventListener('click', () => switchTab('smb'));
if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images'));
function getProviderDisplayName() {
const provider = aiProviderSelect ? aiProviderSelect.value : 'local';
@@ -4696,6 +4877,538 @@ Return ONLY a valid JSON object with an array named "results":
}
});
// ==================== TAB 3: Stash Performer Image Review Logic ====================
const stashImgState = {
performers: [],
selectedIds: new Set(),
candidatesMap: {}, // id -> [ { url, thumbnail, title } ]
selectedImageMap: {}, // id -> selected url
customUrlMap: {}, // id -> custom url string
searchingIds: new Set(),
committingIds: new Set(),
committedIds: new Set(),
isScanning: false,
isSearching: false,
isCommitting: false,
abortSearch: false
};
const btnScanStashImages = document.getElementById('btn-scan-stash-images');
const btnStashFindImagesSelected = document.getElementById('btn-stash-find-images-selected');
const btnStashFindImagesAll = document.getElementById('btn-stash-find-images-all');
const btnStashStopImages = document.getElementById('btn-stash-stop-images');
const btnCommitStashImages = document.getElementById('btn-commit-stash-images');
const toggleStashFemaleOnly = document.getElementById('toggle-stash-female-only');
const toggleStashHasScenes = document.getElementById('toggle-stash-has-scenes');
const stashImagesSearchInput = document.getElementById('stash-images-search-input');
const thStashImgSelectAll = document.getElementById('th-stash-img-select-all');
const stashImgSelectionCount = document.getElementById('stash-img-selection-count');
const stashPerformerCardsContainer = document.getElementById('stash-performer-cards-container');
const stashImgProgressContainer = document.getElementById('stash-img-progress-container');
const stashImgProgressFill = document.getElementById('stash-img-progress-fill');
const stashImgStatMissing = document.getElementById('stash-img-stat-missing');
const stashImgStatFound = document.getElementById('stash-img-stat-found');
const stashImgStatSelected = document.getElementById('stash-img-stat-selected');
const stashImgStatCommitted = document.getElementById('stash-img-stat-committed');
function updateStashImagesStats() {
const missingCount = stashImgState.performers.length;
let foundCount = 0;
let selectedToCommit = 0;
for (const p of stashImgState.performers) {
const cands = stashImgState.candidatesMap[p.id] || [];
if (cands.length > 0) foundCount++;
if (stashImgState.selectedImageMap[p.id] || stashImgState.customUrlMap[p.id]) {
selectedToCommit++;
}
}
if (stashImgStatMissing) stashImgStatMissing.textContent = missingCount;
if (stashImgStatFound) stashImgStatFound.textContent = foundCount;
if (stashImgStatSelected) stashImgStatSelected.textContent = selectedToCommit;
if (stashImgStatCommitted) stashImgStatCommitted.textContent = stashImgState.committedIds.size;
if (stashImagesTabBadge) stashImagesTabBadge.textContent = missingCount;
const hasSelected = stashImgState.selectedIds.size > 0;
if (btnStashFindImagesSelected) btnStashFindImagesSelected.disabled = !hasSelected || stashImgState.isSearching;
if (btnStashFindImagesAll) btnStashFindImagesAll.disabled = missingCount === 0 || stashImgState.isSearching;
if (btnCommitStashImages) btnCommitStashImages.disabled = selectedToCommit === 0 || stashImgState.isCommitting;
if (stashImgSelectionCount) {
stashImgSelectionCount.textContent = `${stashImgState.selectedIds.size} selected`;
}
}
async function scanStashForMissingImages() {
if (stashImgState.isScanning) return;
stashImgState.isScanning = true;
btnScanStashImages.disabled = true;
btnScanStashImages.textContent = 'Scanning Stash...';
try {
const stashUrl = stashServerUrlInput ? stashServerUrlInput.value.trim() : '';
const gender = toggleStashFemaleOnly && toggleStashFemaleOnly.checked ? 'FEMALE' : 'ALL';
const hasScenes = toggleStashHasScenes && toggleStashHasScenes.checked ? 'true' : 'false';
const res = await fetch(`/api/stash/performers/missing-images?url=${encodeURIComponent(stashUrl)}&gender=${gender}&has_scenes=${hasScenes}`);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to scan Stash performers');
}
const data = await res.json();
stashImgState.performers = data.performers || [];
stashImgState.selectedIds.clear();
// Auto select visible performers by default for convenience
stashImgState.performers.forEach(p => stashImgState.selectedIds.add(p.id));
showToast(`Found ${stashImgState.performers.length} performers missing images in Stash`, 'success');
renderStashPerformerCards();
} catch (e) {
showToast(`Stash Scan Error: ${e.message}`, 'error');
} finally {
stashImgState.isScanning = false;
btnScanStashImages.disabled = false;
btnScanStashImages.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Scan Stash Missing Images
`;
updateStashImagesStats();
}
}
async function findCandidateImagesForSinglePerformer(performer) {
if (!performer) return;
stashImgState.searchingIds.add(performer.id);
renderSinglePerformerCard(performer.id);
try {
const res = await fetch('/api/stash/performers/find-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: performer.name,
aliases: performer.alias_list || []
})
});
if (res.ok) {
const data = await res.json();
const candidates = data.candidates || [];
stashImgState.candidatesMap[performer.id] = candidates;
if (candidates.length > 0 && !stashImgState.selectedImageMap[performer.id] && !stashImgState.customUrlMap[performer.id]) {
stashImgState.selectedImageMap[performer.id] = candidates[0].url;
}
}
} catch (e) {
console.warn(`Error finding images for ${performer.name}:`, e);
} finally {
stashImgState.searchingIds.delete(performer.id);
renderSinglePerformerCard(performer.id);
updateStashImagesStats();
}
}
async function runBatchCandidateImageSearch(performersList) {
if (stashImgState.isSearching || performersList.length === 0) return;
stashImgState.isSearching = true;
stashImgState.abortSearch = false;
btnStashStopImages.style.display = 'inline-flex';
btnStashFindImagesSelected.disabled = true;
btnStashFindImagesAll.disabled = true;
stashImgProgressContainer.style.display = 'block';
stashImgProgressFill.style.width = '0%';
let completed = 0;
const total = performersList.length;
for (let i = 0; i < performersList.length; i++) {
if (stashImgState.abortSearch) {
showToast('Image search stopped by user', 'info');
break;
}
const p = performersList[i];
await findCandidateImagesForSinglePerformer(p);
completed++;
stashImgProgressFill.style.width = `${Math.round((completed / total) * 100)}%`;
// Gentle delay
await new Promise(r => setTimeout(r, 400));
}
stashImgState.isSearching = false;
btnStashStopImages.style.display = 'none';
stashImgProgressContainer.style.display = 'none';
updateStashImagesStats();
showToast(`Finished searching candidate images (${completed}/${total})`, 'success');
}
async function commitSinglePerformerImage(performer) {
if (!performer) return;
const chosenUrl = stashImgState.customUrlMap[performer.id] || stashImgState.selectedImageMap[performer.id];
if (!chosenUrl) {
showToast(`Please select or paste an image for ${performer.name}`, 'warning');
return;
}
stashImgState.committingIds.add(performer.id);
renderSinglePerformerCard(performer.id);
try {
const stashUrl = stashServerUrlInput ? stashServerUrlInput.value.trim() : '';
const res = await fetch('/api/stash/performers/commit-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: performer.id,
image_url: chosenUrl,
stash_url: stashUrl
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to commit image to Stash');
}
const data = await res.json();
stashImgState.committedIds.add(performer.id);
performer.image_path = data.image_path;
showToast(`✓ Attached photo to ${performer.name} in Stash`, 'success');
} catch (e) {
showToast(`Commit failed for ${performer.name}: ${e.message}`, 'error');
} finally {
stashImgState.committingIds.delete(performer.id);
renderSinglePerformerCard(performer.id);
updateStashImagesStats();
}
}
async function commitSelectedBatchImages() {
if (stashImgState.isCommitting) return;
const toCommit = [];
for (const p of stashImgState.performers) {
if (!stashImgState.selectedIds.has(p.id)) continue;
if (stashImgState.committedIds.has(p.id)) continue;
const chosenUrl = stashImgState.customUrlMap[p.id] || stashImgState.selectedImageMap[p.id];
if (chosenUrl) {
toCommit.push({ performer: p, image_url: chosenUrl });
}
}
if (toCommit.length === 0) {
showToast('No approved performer images ready to commit', 'warning');
return;
}
stashImgState.isCommitting = true;
btnCommitStashImages.disabled = true;
btnCommitStashImages.textContent = `Committing (0/${toCommit.length})...`;
stashImgProgressContainer.style.display = 'block';
stashImgProgressFill.style.width = '0%';
let successCount = 0;
const stashUrl = stashServerUrlInput ? stashServerUrlInput.value.trim() : '';
for (let i = 0; i < toCommit.length; i++) {
const item = toCommit[i];
stashImgState.committingIds.add(item.performer.id);
renderSinglePerformerCard(item.performer.id);
try {
const res = await fetch('/api/stash/performers/commit-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: item.performer.id,
image_url: item.image_url,
stash_url: stashUrl
})
});
if (res.ok) {
const data = await res.json();
stashImgState.committedIds.add(item.performer.id);
item.performer.image_path = data.image_path;
successCount++;
}
} catch (err) {
console.warn('Batch commit error:', err);
} finally {
stashImgState.committingIds.delete(item.performer.id);
renderSinglePerformerCard(item.performer.id);
}
stashImgProgressFill.style.width = `${Math.round(((i + 1) / toCommit.length) * 100)}%`;
btnCommitStashImages.textContent = `Committing (${i + 1}/${toCommit.length})...`;
}
stashImgState.isCommitting = false;
btnCommitStashImages.disabled = false;
btnCommitStashImages.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
Commit Selected Images to Stash
`;
stashImgProgressContainer.style.display = 'none';
updateStashImagesStats();
showToast(`✓ Successfully committed ${successCount}/${toCommit.length} images to Stash!`, 'success');
}
function generatePerformerCardHTML(p) {
const isSelected = stashImgState.selectedIds.has(p.id);
const isCommitted = stashImgState.committedIds.has(p.id);
const isSearching = stashImgState.searchingIds.has(p.id);
const isCommitting = stashImgState.committingIds.has(p.id);
const candidates = stashImgState.candidatesMap[p.id] || [];
const selectedUrl = stashImgState.customUrlMap[p.id] || stashImgState.selectedImageMap[p.id] || '';
const customUrl = stashImgState.customUrlMap[p.id] || '';
const stashBase = (stashServerUrlInput ? stashServerUrlInput.value.trim() : 'http://servervm.local:9999').replace(/\/+$/, '');
const stashProfileUrl = `${stashBase}/performers/${p.id}`;
let candidatesHTML = '';
if (isSearching) {
candidatesHTML = `
<div style="padding: 1.5rem; text-align: center; color: var(--accent-cyan); font-size: 0.8rem; background: rgba(0,0,0,0.2); border-radius: var(--radius-sm);">
🔍 Searching candidate photos for ${escapeHtml(p.name)}...
</div>
`;
} else if (candidates.length > 0) {
const items = candidates.map((cand, idx) => {
const isActive = selectedUrl === cand.url;
const proxyThumb = `/api/image/proxy?url=${encodeURIComponent(cand.thumbnail || cand.url)}`;
return `
<div class="candidate-image-item ${isActive ? 'active' : ''}" data-performer-id="${p.id}" data-image-url="${escapeHtml(cand.url)}" title="Candidate #${idx + 1} (Click to select)">
<img src="${proxyThumb}" alt="${escapeHtml(p.name)}" loading="lazy" onerror="this.src='${escapeHtml(cand.url)}'" />
${isActive ? '<div class="candidate-active-badge">✓</div>' : ''}
</div>
`;
}).join('');
candidatesHTML = `
<div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-bottom: 0.35rem; display: flex; justify-content: space-between;">
<span>Found <strong>${candidates.length}</strong> photo candidates (click to select):</span>
${selectedUrl ? '<span style="color: var(--accent-green);">✓ Candidate Chosen</span>' : '<span style="color: var(--accent-amber);">Select one</span>'}
</div>
<div class="candidate-images-strip">
${items}
</div>
</div>
`;
} else {
candidatesHTML = `
<div style="padding: 1rem; text-align: center; color: var(--text-dim); font-size: 0.75rem; background: rgba(0,0,0,0.15); border-radius: var(--radius-sm); border: 1px dashed var(--border-color);">
No candidate photos fetched yet. Click "⚡ Find Images" to search web portraits.
</div>
`;
}
return `
<div class="performer-review-card ${isSelected ? 'selected' : ''} ${isCommitted ? 'committed' : ''}" id="performer-card-${p.id}">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.6rem;">
<input type="checkbox" class="stash-performer-checkbox" data-id="${p.id}" ${isSelected ? 'checked' : ''} style="cursor: pointer;" />
<div>
<div style="font-weight: 700; font-size: 0.95rem; color: #fff; display: flex; align-items: center; gap: 0.4rem;">
<span>${escapeHtml(p.name)}</span>
${isCommitted ? '<span class="badge" style="background: rgba(34,197,94,0.2); color: var(--accent-green); border: 1px solid rgba(34,197,94,0.4);">✓ Attached to Stash</span>' : ''}
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); display: flex; gap: 0.4rem; margin-top: 0.15rem;">
<span>Scenes: <strong>${p.scene_count || 0}</strong></span>
<span>•</span>
<span>Stash ID: #${p.id}</span>
${p.gender ? `<span>•</span><span style="color: var(--accent-rose);">${p.gender}</span>` : ''}
</div>
</div>
</div>
<a href="${stashProfileUrl}" target="_blank" class="btn btn-secondary btn-sm" style="padding: 0.2rem 0.5rem; font-size: 0.7rem; text-decoration: none;" title="Open in Stash WebUI">
🔗 Stash
</a>
</div>
<!-- Candidates Gallery -->
${candidatesHTML}
<!-- Custom URL / Paste input -->
<div style="display: flex; gap: 0.4rem; align-items: center;">
<input type="text" class="form-control font-mono stash-custom-img-input" data-id="${p.id}" placeholder="Or paste custom image URL..." value="${escapeHtml(customUrl)}" style="font-size: 0.75rem; padding: 0.35rem 0.6rem; height: auto;" />
${customUrl ? `<button type="button" class="btn btn-secondary btn-sm btn-clear-custom-url" data-id="${p.id}" style="padding: 0.35rem 0.5rem; font-size: 0.7rem;">✕</button>` : ''}
</div>
<!-- Actions Footer -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-top: 0.5rem; border-top: 1px solid var(--border-color); gap: 0.5rem;">
<button type="button" class="btn btn-secondary btn-sm btn-single-find-images" data-id="${p.id}" ${isSearching ? 'disabled' : ''}>
${isSearching ? 'Searching...' : '⚡ Find Images'}
</button>
<button type="button" class="btn btn-success btn-sm btn-single-commit-image" data-id="${p.id}" ${!selectedUrl || isCommitting ? 'disabled' : ''}>
${isCommitting ? 'Committing...' : isCommitted ? '✓ Re-Commit' : '💾 Commit to Stash'}
</button>
</div>
</div>
`;
}
function renderSinglePerformerCard(performerId) {
const cardEl = document.getElementById(`performer-card-${performerId}`);
if (!cardEl) return;
const p = stashImgState.performers.find(x => x.id === performerId);
if (!p) return;
const temp = document.createElement('div');
temp.innerHTML = generatePerformerCardHTML(p).trim();
const newCard = temp.firstElementChild;
cardEl.replaceWith(newCard);
attachPerformerCardEventListeners(newCard, p);
}
function attachPerformerCardEventListeners(cardEl, p) {
const checkbox = cardEl.querySelector('.stash-performer-checkbox');
if (checkbox) {
checkbox.addEventListener('change', () => {
if (checkbox.checked) stashImgState.selectedIds.add(p.id);
else stashImgState.selectedIds.delete(p.id);
cardEl.classList.toggle('selected', checkbox.checked);
updateStashImagesStats();
});
}
cardEl.querySelectorAll('.candidate-image-item').forEach(item => {
item.addEventListener('click', () => {
const imgUrl = item.getAttribute('data-image-url');
stashImgState.selectedImageMap[p.id] = imgUrl;
stashImgState.customUrlMap[p.id] = '';
renderSinglePerformerCard(p.id);
updateStashImagesStats();
});
});
const customInput = cardEl.querySelector('.stash-custom-img-input');
if (customInput) {
customInput.addEventListener('input', () => {
const val = customInput.value.trim();
stashImgState.customUrlMap[p.id] = val;
if (val) stashImgState.selectedImageMap[p.id] = val;
const commitBtn = cardEl.querySelector('.btn-single-commit-image');
if (commitBtn) commitBtn.disabled = !val;
updateStashImagesStats();
});
}
const btnClear = cardEl.querySelector('.btn-clear-custom-url');
if (btnClear) {
btnClear.addEventListener('click', () => {
stashImgState.customUrlMap[p.id] = '';
renderSinglePerformerCard(p.id);
updateStashImagesStats();
});
}
const btnSingleFind = cardEl.querySelector('.btn-single-find-images');
if (btnSingleFind) {
btnSingleFind.addEventListener('click', () => {
findCandidateImagesForSinglePerformer(p);
});
}
const btnSingleCommit = cardEl.querySelector('.btn-single-commit-image');
if (btnSingleCommit) {
btnSingleCommit.addEventListener('click', () => {
commitSinglePerformerImage(p);
});
}
}
function renderStashPerformerCards() {
if (!stashPerformerCardsContainer) return;
const query = (stashImagesSearchInput ? stashImagesSearchInput.value.trim().toLowerCase() : '');
const filtered = stashImgState.performers.filter(p => {
if (!query) return true;
return (p.name || '').toLowerCase().includes(query);
});
if (filtered.length === 0) {
stashPerformerCardsContainer.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-state-icon">🔍</div>
<h3>No matching performers found</h3>
<p>${query ? 'Try adjusting your search filter' : 'Click "Scan Stash Missing Images" to load performers'}</p>
</div>
`;
updateStashImagesStats();
return;
}
stashPerformerCardsContainer.innerHTML = filtered.map(p => generatePerformerCardHTML(p)).join('');
filtered.forEach(p => {
const cardEl = document.getElementById(`performer-card-${p.id}`);
if (cardEl) attachPerformerCardEventListeners(cardEl, p);
});
updateStashImagesStats();
}
// Attach Tab 3 Toolbar Events
if (btnScanStashImages) btnScanStashImages.addEventListener('click', scanStashForMissingImages);
if (btnStashFindImagesSelected) {
btnStashFindImagesSelected.addEventListener('click', () => {
const selected = stashImgState.performers.filter(p => stashImgState.selectedIds.has(p.id));
runBatchCandidateImageSearch(selected);
});
}
if (btnStashFindImagesAll) {
btnStashFindImagesAll.addEventListener('click', () => {
runBatchCandidateImageSearch(stashImgState.performers);
});
}
if (btnStashStopImages) {
btnStashStopImages.addEventListener('click', () => {
stashImgState.abortSearch = true;
btnStashStopImages.textContent = 'Stopping...';
});
}
if (btnCommitStashImages) {
btnCommitStashImages.addEventListener('click', commitSelectedBatchImages);
}
if (toggleStashFemaleOnly) toggleStashFemaleOnly.addEventListener('change', scanStashForMissingImages);
if (toggleStashHasScenes) toggleStashHasScenes.addEventListener('change', scanStashForMissingImages);
if (stashImagesSearchInput) {
stashImagesSearchInput.addEventListener('input', () => {
renderStashPerformerCards();
});
}
if (thStashImgSelectAll) {
thStashImgSelectAll.addEventListener('change', () => {
const checked = thStashImgSelectAll.checked;
stashImgState.performers.forEach(p => {
if (checked) stashImgState.selectedIds.add(p.id);
else stashImgState.selectedIds.delete(p.id);
});
document.querySelectorAll('.stash-performer-checkbox').forEach(cb => { cb.checked = checked; });
document.querySelectorAll('.performer-review-card').forEach(c => { c.classList.toggle('selected', checked); });
updateStashImagesStats();
});
}
// Init on load
window.addEventListener('DOMContentLoaded', async () => {
loadConfig();
+271 -2
View File
@@ -383,6 +383,64 @@ def query_stash_batch(items, stash_url=None):
return results
def search_performer_candidate_images(name, aliases=None, limit=6):
queries = [
f"{name} actress portrait",
f"{name} model photoshoot",
f"{name} portrait"
]
if aliases and isinstance(aliases, list):
for a in aliases[:2]:
if a and a.lower() != name.lower():
queries.append(f"{a} actress portrait")
results = []
seen = set()
for q in queries:
url = f"https://www.bing.com/images/search?q={urllib.parse.quote(q)}&FORM=HDRSC2"
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}
)
try:
with urllib.request.urlopen(req, timeout=6) as res:
html = res.read().decode('utf-8', errors='ignore')
murls = re.findall(r'&quot;murl&quot;:&quot;(http[^&]+)&quot;', html)
turls = re.findall(r'&quot;turl&quot;:&quot;(http[^&]+)&quot;', html)
for i, u in enumerate(murls):
clean_u = u.replace('\\/', '/')
if clean_u not in seen:
seen.add(clean_u)
t = turls[i].replace('\\/', '/') if i < len(turls) else clean_u
results.append({'url': clean_u, 'thumbnail': t})
if len(results) >= limit:
break
except Exception:
pass
if len(results) >= limit:
break
return results
def fetch_image_as_base64(image_url):
req = urllib.request.Request(
image_url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': image_url
}
)
with urllib.request.urlopen(req, timeout=12) as resp:
content_type = resp.headers.get('Content-Type', 'image/jpeg')
if not content_type or not content_type.startswith('image/'):
content_type = 'image/jpeg'
raw_data = resp.read()
b64_str = base64.b64encode(raw_data).decode('utf-8')
return f"data:{content_type};base64,{b64_str}"
def normalize_host(host_str):
if not host_str:
return DEFAULT_QB_HOST
@@ -558,9 +616,13 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
# Performer Image Proxy API
elif self.path.startswith('/api/performer/image'):
self.handle_performer_image()
# Stash Status API
elif self.path.startswith('/api/image/proxy'):
self.handle_image_proxy()
# Stash Status & Performer Review APIs
elif self.path.startswith('/api/stash/status'):
self.handle_stash_status()
elif self.path.startswith('/api/stash/performers/missing-images'):
self.handle_stash_missing_images()
# SMB API
elif self.path.startswith('/api/smb/status'):
self.handle_smb_status()
@@ -596,11 +658,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_db_batch_save()
elif self.path == '/api/db/delete':
self.handle_db_delete()
# Stash Lookup APIs
# Stash Lookup & Image Review APIs
elif self.path == '/api/stash/lookup':
self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup()
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()
# SMB API Moves & Deletes
elif self.path == '/api/smb/move':
self.handle_smb_move()
@@ -995,6 +1063,36 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception:
pass
def handle_image_proxy(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
target_url = params.get('url', [''])[0]
if not target_url:
self.send_response(400)
self.end_headers()
return
try:
req = urllib.request.Request(
target_url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': target_url
}
)
with urllib.request.urlopen(req, timeout=10) as resp:
content_type = resp.headers.get('Content-Type', 'image/jpeg')
img_data = resp.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Cache-Control', 'public, max-age=86400')
self.send_cors_headers()
self.end_headers()
self.wfile.write(img_data)
except Exception:
self.send_response(404)
self.end_headers()
# ==================== Stash Server API Handlers ====================
def handle_stash_status(self):
parsed = urllib.parse.urlparse(self.path)
@@ -1073,6 +1171,177 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "results": []}).encode('utf-8'))
def handle_stash_missing_images(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('url', [''])[0] or DEFAULT_STASH_URL
gender_filter = params.get('gender', ['FEMALE'])[0].upper()
only_scenes = params.get('has_scenes', ['true'])[0].lower() in ['true', '1']
query = '''
query {
allPerformers {
id
name
disambiguation
gender
image_path
scene_count
image_count
alias_list
country
birthdate
}
}
'''
try:
res = execute_stash_graphql(query, stash_url, timeout=15)
all_p = res.get('data', {}).get('allPerformers', [])
missing = []
for p in all_p:
img_path = p.get('image_path') or ''
is_missing = not img_path or 'default=true' in img_path or 'default' in img_path.lower()
if not is_missing:
continue
p_gender = (p.get('gender') or '').upper()
if gender_filter != 'ALL':
if gender_filter == 'FEMALE' and p_gender not in ['FEMALE', '', 'NONE', None]:
continue
elif gender_filter != 'FEMALE' and p_gender != gender_filter:
continue
scenes = p.get('scene_count') or 0
if only_scenes and scenes <= 0:
continue
missing.append(p)
# Sort: performers with more scenes first, then by name
missing.sort(key=lambda x: (-(x.get('scene_count') or 0), x.get('name', '').lower()))
self.send_json_response(200, {
"performers": missing,
"count": len(missing),
"total_in_stash": len(all_p)
})
except Exception as e:
self.send_json_response(500, {"error": f"Failed fetching performers from Stash: {str(e)}", "performers": []})
def handle_stash_find_images(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'))
name = payload.get('name', '')
aliases = payload.get('aliases', [])
candidates = search_performer_candidate_images(name, aliases)
self.send_json_response(200, {
"name": name,
"candidates": candidates,
"count": len(candidates)
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "candidates": []})
def handle_stash_commit_image(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'))
performer_id = str(payload.get('id', ''))
image_url = payload.get('image_url', '')
base64_data = payload.get('base64', '')
stash_url = payload.get('stash_url', '') or DEFAULT_STASH_URL
if not performer_id:
self.send_json_response(400, {"error": "Missing performer ID"})
return
if not base64_data and image_url:
base64_data = fetch_image_as_base64(image_url)
if not base64_data:
self.send_json_response(400, {"error": "Missing image data or URL"})
return
mutation = f'''
mutation {{
performerUpdate(input: {{ id: {json.dumps(performer_id)}, image: {json.dumps(base64_data)} }}) {{
id
name
image_path
}}
}}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if 'errors' in res and res['errors']:
err_msg = res['errors'][0].get('message', 'GraphQL error')
self.send_json_response(500, {"error": err_msg})
return
updated = res.get('data', {}).get('performerUpdate', {})
self.send_json_response(200, {
"success": True,
"id": performer_id,
"name": updated.get('name'),
"image_path": updated.get('image_path')
})
except Exception as e:
self.send_json_response(500, {"error": f"Failed committing image to Stash: {str(e)}"})
def handle_stash_batch_commit_images(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'))
updates = payload.get('updates', [])
stash_url = payload.get('stash_url', '') or DEFAULT_STASH_URL
results = []
for item in updates:
p_id = str(item.get('id', ''))
img_url = item.get('image_url', '')
b64 = item.get('base64', '')
try:
if not b64 and img_url:
b64 = fetch_image_as_base64(img_url)
if not b64:
results.append({"id": p_id, "success": False, "error": "No image data"})
continue
mutation = f'''
mutation {{
performerUpdate(input: {{ id: {json.dumps(p_id)}, image: {json.dumps(b64)} }}) {{
id
name
image_path
}}
}}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if 'errors' in res and res['errors']:
err_msg = res['errors'][0].get('message', 'GraphQL error')
results.append({"id": p_id, "success": False, "error": err_msg})
else:
updated = res.get('data', {}).get('performerUpdate', {})
results.append({
"id": p_id,
"success": True,
"name": updated.get('name'),
"image_path": updated.get('image_path')
})
except Exception as ex:
results.append({"id": p_id, "success": False, "error": str(ex)})
self.send_json_response(200, {
"results": results,
"total": len(updates),
"successful": len([r for r in results if r.get('success')])
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "results": []})
# ==================== SMB File Management Handlers ====================
def handle_smb_status(self):
mounted, msg = ensure_smb_mounted()