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:
@@ -776,6 +776,85 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
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 {
|
.btn-preview-play {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -954,6 +1033,11 @@
|
|||||||
<span>SMB Share Sorter (videodownloader → /isolation/videos)</span>
|
<span>SMB Share Sorter (videodownloader → /isolation/videos)</span>
|
||||||
<span class="tab-badge" id="smb-tab-badge">0</span>
|
<span class="tab-badge" id="smb-tab-badge">0</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
|
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
|
||||||
@@ -1363,6 +1447,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 (>= 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>
|
</main>
|
||||||
|
|
||||||
<!-- File Modal -->
|
<!-- 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
|
// Tab Switching Logic
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
if (tabName === 'smb') {
|
tabBtnQb.classList.toggle('active', tabName === 'qb');
|
||||||
tabBtnSmb.classList.add('active');
|
tabBtnSmb.classList.toggle('active', tabName === 'smb');
|
||||||
tabBtnQb.classList.remove('active');
|
if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images');
|
||||||
tabPanelSmb.classList.add('active');
|
|
||||||
tabPanelQb.classList.remove('active');
|
tabPanelQb.classList.toggle('active', tabName === 'qb');
|
||||||
} else {
|
tabPanelSmb.classList.toggle('active', tabName === 'smb');
|
||||||
tabBtnQb.classList.add('active');
|
if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images');
|
||||||
tabBtnSmb.classList.remove('active');
|
|
||||||
tabPanelQb.classList.add('active');
|
|
||||||
tabPanelSmb.classList.remove('active');
|
|
||||||
}
|
|
||||||
localStorage.setItem('qb_active_tab', tabName);
|
localStorage.setItem('qb_active_tab', tabName);
|
||||||
}
|
}
|
||||||
|
|
||||||
tabBtnQb.addEventListener('click', () => switchTab('qb'));
|
tabBtnQb.addEventListener('click', () => switchTab('qb'));
|
||||||
tabBtnSmb.addEventListener('click', () => switchTab('smb'));
|
tabBtnSmb.addEventListener('click', () => switchTab('smb'));
|
||||||
|
if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images'));
|
||||||
|
|
||||||
function getProviderDisplayName() {
|
function getProviderDisplayName() {
|
||||||
const provider = aiProviderSelect ? aiProviderSelect.value : 'local';
|
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
|
// Init on load
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
|
|||||||
@@ -383,6 +383,64 @@ def query_stash_batch(items, stash_url=None):
|
|||||||
|
|
||||||
return results
|
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'"murl":"(http[^&]+)"', html)
|
||||||
|
turls = re.findall(r'"turl":"(http[^&]+)"', 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):
|
def normalize_host(host_str):
|
||||||
if not host_str:
|
if not host_str:
|
||||||
return DEFAULT_QB_HOST
|
return DEFAULT_QB_HOST
|
||||||
@@ -558,9 +616,13 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
# Performer Image Proxy API
|
# Performer Image Proxy API
|
||||||
elif self.path.startswith('/api/performer/image'):
|
elif self.path.startswith('/api/performer/image'):
|
||||||
self.handle_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'):
|
elif self.path.startswith('/api/stash/status'):
|
||||||
self.handle_stash_status()
|
self.handle_stash_status()
|
||||||
|
elif self.path.startswith('/api/stash/performers/missing-images'):
|
||||||
|
self.handle_stash_missing_images()
|
||||||
# SMB API
|
# SMB API
|
||||||
elif self.path.startswith('/api/smb/status'):
|
elif self.path.startswith('/api/smb/status'):
|
||||||
self.handle_smb_status()
|
self.handle_smb_status()
|
||||||
@@ -596,11 +658,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.handle_db_batch_save()
|
self.handle_db_batch_save()
|
||||||
elif self.path == '/api/db/delete':
|
elif self.path == '/api/db/delete':
|
||||||
self.handle_db_delete()
|
self.handle_db_delete()
|
||||||
# Stash Lookup APIs
|
# Stash Lookup & Image Review APIs
|
||||||
elif self.path == '/api/stash/lookup':
|
elif self.path == '/api/stash/lookup':
|
||||||
self.handle_stash_lookup()
|
self.handle_stash_lookup()
|
||||||
elif self.path == '/api/stash/batch-lookup':
|
elif self.path == '/api/stash/batch-lookup':
|
||||||
self.handle_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
|
# SMB API Moves & Deletes
|
||||||
elif self.path == '/api/smb/move':
|
elif self.path == '/api/smb/move':
|
||||||
self.handle_smb_move()
|
self.handle_smb_move()
|
||||||
@@ -995,6 +1063,36 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 ====================
|
# ==================== Stash Server API Handlers ====================
|
||||||
def handle_stash_status(self):
|
def handle_stash_status(self):
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
@@ -1073,6 +1171,177 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({"error": str(e), "results": []}).encode('utf-8'))
|
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 ====================
|
# ==================== SMB File Management Handlers ====================
|
||||||
def handle_smb_status(self):
|
def handle_smb_status(self):
|
||||||
mounted, msg = ensure_smb_mounted()
|
mounted, msg = ensure_smb_mounted()
|
||||||
|
|||||||
Reference in New Issue
Block a user