From a273804dde42955d1368b4024277a992e79323bc Mon Sep 17 00:00:00 2001 From: david Date: Mon, 17 Aug 2026 10:46:46 -0400 Subject: [PATCH] feat: Add Stash Performer Image Review tab with AI/web candidate photo finder, interactive visual review grid, and batch commit to Stash --- qbittorrent_performer_sorter.html | 735 +++++++++++++++++++++++++++++- server.py | 273 ++++++++++- 2 files changed, 995 insertions(+), 13 deletions(-) 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 +
+
+ + +
+
+ + + + + + + + + +
+ +
+ + + + +
+ +
+
+
+ +
+
+
+ + +
+
+
+ Stash Performers Missing Photos & Candidate Review +
+
+ + 0 selected +
+
+ +
+
+
🖼️
+

No performers scanned yet

+

Click "Scan Stash Missing Images" to detect performers in your Stash instance without profile pictures.

+
+
+
+
@@ -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 ` +
+ ${escapeHtml(p.name)} + ${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 ` +
+ +
+
+ +
+
+ ${escapeHtml(p.name)} + ${isCommitted ? '✓ Attached to Stash' : ''} +
+
+ Scenes: ${p.scene_count || 0} + • + Stash ID: #${p.id} + ${p.gender ? `•${p.gender}` : ''} +
+
+
+ + + 🔗 Stash + +
+ + + ${candidatesHTML} + + +
+ + ${customUrl ? `` : ''} +
+ + +
+ + + +
+
+ `; + } + + 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()