diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html
index 8a9f989..b4ae8a2 100644
--- a/qbittorrent_performer_sorter.html
+++ b/qbittorrent_performer_sorter.html
@@ -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 @@
SMB Share Sorter (videodownloader → /isolation/videos)
0
+
@@ -1363,6 +1447,100 @@
+
+
+
+
+
+
+ Performers Missing Photos
+ 0
+
+
+ Candidate Photos Found
+ 0
+
+
+ Selected To Commit
+ 0
+
+
+ Attached to Stash
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 = `
+
+ 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 = `
+
+ 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 = `
+
+ 🔍 Searching candidate photos for ${escapeHtml(p.name)}...
+
+ `;
+ } 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 `
+
+

+ ${isActive ? '
✓
' : ''}
+
+ `;
+ }).join('');
+
+ candidatesHTML = `
+
+
+ Found ${candidates.length} photo candidates (click to select):
+ ${selectedUrl ? '✓ Candidate Chosen' : 'Select one'}
+
+
+ ${items}
+
+
+ `;
+ } else {
+ candidatesHTML = `
+
+ No candidate photos fetched yet. Click "⚡ Find Images" to search web portraits.
+
+ `;
+ }
+
+ return `
+
+ `;
+ }
+
+ 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 = `
+
+
🔍
+
No matching performers found
+
${query ? 'Try adjusting your search filter' : 'Click "Scan Stash Missing Images" to load performers'}
+
+ `;
+ 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();
diff --git a/server.py b/server.py
index 6f83f1f..4578478 100644
--- a/server.py
+++ b/server.py
@@ -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'"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):
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()