Enhancement: Direct Stash instance integration with scene performer matching, female prioritization & auto-lookup

This commit is contained in:
david
2026-08-17 09:20:31 -04:00
parent 6aadc53335
commit 8e16c362b9
2 changed files with 561 additions and 6 deletions
+261 -6
View File
@@ -827,6 +827,10 @@
<span class="status-indicator online" id="smb-status-dot"></span> <span class="status-indicator online" id="smb-status-dot"></span>
<span id="smb-status-text">SMB: /mnt/isolation</span> <span id="smb-status-text">SMB: /mnt/isolation</span>
</div> </div>
<div class="status-pill" id="stash-status-pill">
<span class="status-indicator online" id="stash-status-dot"></span>
<span id="stash-status-text">Stash: Checking...</span>
</div>
<div class="status-pill" id="db-status-pill"> <div class="status-pill" id="db-status-pill">
<span class="status-indicator online" id="db-status-dot"></span> <span class="status-indicator online" id="db-status-dot"></span>
<span id="db-status-text">SQLite: 0 Cached</span> <span id="db-status-text">SQLite: 0 Cached</span>
@@ -940,6 +944,39 @@
</div> </div>
</div> </div>
<!-- Stash Server Integration Section -->
<div style="margin-top: 1.25rem; padding-top: 1rem; border-top: 1px dashed var(--border-color);">
<div style="font-size: 0.85rem; font-weight: 600; color: var(--accent-cyan); margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>⚡ Stash App Integration (servervm.local:9999)</span>
</div>
<button class="btn btn-secondary btn-sm" id="btn-test-stash" style="font-size: 0.7rem; padding: 0.25rem 0.6rem;">
⚡ Test Stash Connection
</button>
</div>
<div class="grid-form">
<div class="form-group">
<label for="stash-endpoint">Stash GraphQL Endpoint</label>
<input type="text" id="stash-endpoint" class="form-control font-mono" value="http://servervm.local:9999/graphql" placeholder="http://servervm.local:9999/graphql" />
</div>
<div class="form-group">
<label for="stash-auto-lookup">Auto-Lookup Stash on Scan</label>
<select id="stash-auto-lookup" class="form-control">
<option value="true" selected>Enabled (Instant 100% Match & Zero AI Quota)</option>
<option value="false">Disabled (Manual / AI Only)</option>
</select>
</div>
<div class="form-group">
<label for="stash-gender-prio">Co-Star Folder Preference</label>
<select id="stash-gender-prio" class="form-control">
<option value="female" selected>Female Performer(s) as Primary Destination</option>
<option value="first">First Listed Performer as Primary</option>
</select>
</div>
</div>
</div>
<!-- Local / Network LLM Fallback Section --> <!-- Local / Network LLM Fallback Section -->
<div style="margin-top: 1.25rem; padding-top: 1rem; border-top: 1px dashed var(--border-color);"> <div style="margin-top: 1.25rem; padding-top: 1rem; border-top: 1px dashed var(--border-color);">
<div style="font-size: 0.85rem; font-weight: 600; color: var(--accent-cyan); margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;"> <div style="font-size: 0.85rem; font-weight: 600; color: var(--accent-cyan); margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
@@ -1050,6 +1087,10 @@
Fetch Torrents from /downloads Fetch Torrents from /downloads
</button> </button>
<button class="btn btn-secondary" id="btn-qb-stash-lookup" disabled title="Instantly match selected torrents with Stash database">
⚡ Stash Match (Selected)
</button>
<button class="btn btn-secondary" id="btn-ai-analyze-selected" disabled> <button class="btn btn-secondary" id="btn-ai-analyze-selected" disabled>
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Run AI Analysis (Selected) Run AI Analysis (Selected)
@@ -1147,6 +1188,10 @@
Scan SMB Files (/videodownloader) Scan SMB Files (/videodownloader)
</button> </button>
<button class="btn btn-secondary" id="btn-smb-stash-lookup" disabled title="Instantly match selected SMB files with Stash database">
⚡ Stash Match (Selected)
</button>
<button class="btn btn-secondary" id="btn-smb-analyze-selected" disabled> <button class="btn btn-secondary" id="btn-smb-analyze-selected" disabled>
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Run AI Analysis (Selected) Run AI Analysis (Selected)
@@ -1413,6 +1458,16 @@
const smbProgressBarContainer = document.getElementById('smb-progress-container'); const smbProgressBarContainer = document.getElementById('smb-progress-container');
const smbProgressBarFill = document.getElementById('smb-progress-fill'); const smbProgressBarFill = document.getElementById('smb-progress-fill');
// Stash Tab & Status Elements
const stashEndpointInput = document.getElementById('stash-endpoint');
const stashAutoLookupSelect = document.getElementById('stash-auto-lookup');
const stashGenderPrioSelect = document.getElementById('stash-gender-prio');
const btnTestStash = document.getElementById('btn-test-stash');
const stashStatusDot = document.getElementById('stash-status-dot');
const stashStatusText = document.getElementById('stash-status-text');
const btnQbStashLookup = document.getElementById('btn-qb-stash-lookup');
const btnSmbStashLookup = document.getElementById('btn-smb-stash-lookup');
const btnSaveConfig = document.getElementById('btn-save-config'); const btnSaveConfig = document.getElementById('btn-save-config');
const btnTestConn = document.getElementById('btn-test-conn'); const btnTestConn = document.getElementById('btn-test-conn');
const configHeader = document.getElementById('config-header'); const configHeader = document.getElementById('config-header');
@@ -1662,6 +1717,10 @@
if (cfg.smbSourcePath && smbSourcePathInput) smbSourcePathInput.value = cfg.smbSourcePath; if (cfg.smbSourcePath && smbSourcePathInput) smbSourcePathInput.value = cfg.smbSourcePath;
if (cfg.smbDestRoot && smbDestRootInput) smbDestRootInput.value = cfg.smbDestRoot; if (cfg.smbDestRoot && smbDestRootInput) smbDestRootInput.value = cfg.smbDestRoot;
if (cfg.stashEndpoint && stashEndpointInput) stashEndpointInput.value = cfg.stashEndpoint;
if (cfg.stashAutoLookup && stashAutoLookupSelect) stashAutoLookupSelect.value = cfg.stashAutoLookup;
if (cfg.stashGenderPrio && stashGenderPrioSelect) stashGenderPrioSelect.value = cfg.stashGenderPrio;
if (cfg.namingFormat) namingFormatSelect.value = cfg.namingFormat; if (cfg.namingFormat) namingFormatSelect.value = cfg.namingFormat;
if (cfg.rateLimitDelay && rateLimitDelaySelect) rateLimitDelaySelect.value = cfg.rateLimitDelay; if (cfg.rateLimitDelay && rateLimitDelaySelect) rateLimitDelaySelect.value = cfg.rateLimitDelay;
if (cfg.aiBatchSize && aiBatchSizeSelect) aiBatchSizeSelect.value = cfg.aiBatchSize; if (cfg.aiBatchSize && aiBatchSizeSelect) aiBatchSizeSelect.value = cfg.aiBatchSize;
@@ -1694,6 +1753,9 @@
enableLocalFallback: enableLocalFallbackCheckbox ? enableLocalFallbackCheckbox.checked : true, enableLocalFallback: enableLocalFallbackCheckbox ? enableLocalFallbackCheckbox.checked : true,
smbSourcePath: smbSourcePathInput ? smbSourcePathInput.value.trim() : '/mnt/isolation/videos/videodownloader', smbSourcePath: smbSourcePathInput ? smbSourcePathInput.value.trim() : '/mnt/isolation/videos/videodownloader',
smbDestRoot: smbDestRootInput ? smbDestRootInput.value.trim() : '/mnt/isolation/videos', smbDestRoot: smbDestRootInput ? smbDestRootInput.value.trim() : '/mnt/isolation/videos',
stashEndpoint: stashEndpointInput ? stashEndpointInput.value.trim() : 'http://servervm.local:9999/graphql',
stashAutoLookup: stashAutoLookupSelect ? stashAutoLookupSelect.value : 'true',
stashGenderPrio: stashGenderPrioSelect ? stashGenderPrioSelect.value : 'female',
namingFormat: namingFormatSelect.value, namingFormat: namingFormatSelect.value,
rateLimitDelay: rateLimitDelaySelect ? rateLimitDelaySelect.value : '1500', rateLimitDelay: rateLimitDelaySelect ? rateLimitDelaySelect.value : '1500',
aiBatchSize: aiBatchSizeSelect ? aiBatchSizeSelect.value : '5', aiBatchSize: aiBatchSizeSelect ? aiBatchSizeSelect.value : '5',
@@ -1704,10 +1766,116 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
updateAIStatusBadge(); updateAIStatusBadge();
if (showToastNotification) { if (showToastNotification) {
showToast('Settings saved to browser storage', 'success'); showToast('Settings saved successfully', 'success');
} }
} }
// ==================== Stash Integration Helpers ====================
async function checkStashStatus() {
const endpoint = stashEndpointInput ? stashEndpointInput.value.trim() : 'http://servervm.local:9999/graphql';
try {
const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000';
const url = (isServingFromProxy ? '/api/stash/status?url=' : 'http://localhost:8000/api/stash/status?url=') + encodeURIComponent(endpoint);
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
if (data.online) {
stashStatusDot.className = 'status-indicator online';
const kScenes = (data.sceneCount / 1000).toFixed(1);
stashStatusText.textContent = `Stash: ${kScenes}k Scenes`;
return data;
}
}
stashStatusDot.className = 'status-indicator offline';
stashStatusText.textContent = 'Stash: Offline';
} catch (e) {
stashStatusDot.className = 'status-indicator offline';
stashStatusText.textContent = 'Stash: Offline';
}
return null;
}
if (btnTestStash) {
btnTestStash.addEventListener('click', async () => {
btnTestStash.disabled = true;
btnTestStash.textContent = 'Testing...';
const res = await checkStashStatus();
btnTestStash.disabled = false;
btnTestStash.textContent = '⚡ Test Stash Connection';
if (res && res.online) {
showToast(`Connected to Stash (${res.version})! Found ${res.sceneCount.toLocaleString()} scenes & ${res.performerCount.toLocaleString()} performers.`, 'success');
} else {
showToast(`Failed to connect to Stash at ${stashEndpointInput.value}`, 'error');
}
});
}
async function matchItemsWithStash(items, itemType) {
if (!items || items.length === 0) return 0;
const stashUrl = stashEndpointInput ? stashEndpointInput.value.trim() : 'http://servervm.local:9999/graphql';
const destRoot = itemType === 'smb'
? ((smbDestRootInput ? smbDestRootInput.value.trim() : '/mnt/isolation/videos').replace(/\/+$/, '') || '/mnt/isolation/videos')
: (destRootInput.value.trim().replace(/\/+$/, '') || '/isolation/videos');
const genderPrio = stashGenderPrioSelect ? stashGenderPrioSelect.value : 'female';
const payloadItems = items.map(it => ({
id: itemType === 'qb' ? it.hash : it.path,
name: it.name,
path: it.path || it.name,
item_type: itemType
}));
const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000';
const endpoint = isServingFromProxy ? '/api/stash/batch-lookup' : 'http://localhost:8000/api/stash/batch-lookup';
let matchedCount = 0;
const batchSaved = [];
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: payloadItems, stash_url: stashUrl })
});
if (res.ok) {
const resData = await res.json();
const results = resData.results || [];
for (let i = 0; i < items.length; i++) {
const match = results[i];
if (match && match.allPerformers && match.allPerformers.length > 0) {
const item = items[i];
let primaryFormatted = match.primaryPerformer;
if (genderPrio === 'female' && match.femalePerformers && match.femalePerformers.length > 0) {
primaryFormatted = match.femalePerformers[0];
}
item.analysis = match;
item.selectedPerformer = primaryFormatted;
item.targetPath = `${destRoot}/${primaryFormatted}`;
if (item.moveStatus !== 'moved') item.moveStatus = 'ready';
item.errorMessage = '';
batchSaved.push(item);
matchedCount++;
if (itemType === 'smb') renderSmbRow(item.path);
else renderRow(item.hash);
}
}
}
} catch (err) {
console.warn('Stash batch lookup error:', err);
}
if (batchSaved.length > 0) {
await saveBatchToDb(batchSaved, itemType);
if (itemType === 'smb') updateSmbStats();
else updateStats();
}
return matchedCount;
}
// Auto-save listeners on all form controls // Auto-save listeners on all form controls
const formFields = [ const formFields = [
qbHostInput, qbUserInput, qbPassInput, qbHostInput, qbUserInput, qbPassInput,
@@ -2305,6 +2473,18 @@
qbTabBadge.textContent = qbState.torrents.length; qbTabBadge.textContent = qbState.torrents.length;
renderTable(); renderTable();
updateStats(); updateStats();
// Auto Stash lookup
if (stashAutoLookupSelect && stashAutoLookupSelect.value === 'true') {
const unanalyzed = qbState.torrents.filter(t => !t.analysis && t.moveStatus !== 'moved');
if (unanalyzed.length > 0) {
const stashMatches = await matchItemsWithStash(unanalyzed, 'qb');
if (stashMatches > 0) {
showToast(`⚡ Stash matched ${stashMatches} / ${unanalyzed.length} torrents!`, 'success');
}
}
}
showToast(`Loaded ${qbState.torrents.length} torrents (${cachedLoadedCount} restored from SQLite cache)`, 'success'); showToast(`Loaded ${qbState.torrents.length} torrents (${cachedLoadedCount} restored from SQLite cache)`, 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@@ -2986,6 +3166,18 @@ Return ONLY valid JSON matching:
smbTabBadge.textContent = smbState.files.length; smbTabBadge.textContent = smbState.files.length;
renderSmbTable(); renderSmbTable();
updateSmbStats(); updateSmbStats();
// Auto Stash lookup
if (stashAutoLookupSelect && stashAutoLookupSelect.value === 'true') {
const unanalyzed = smbState.files.filter(f => !f.analysis && f.moveStatus !== 'moved');
if (unanalyzed.length > 0) {
const stashMatches = await matchItemsWithStash(unanalyzed, 'smb');
if (stashMatches > 0) {
showToast(`⚡ Stash matched ${stashMatches} / ${unanalyzed.length} video files!`, 'success');
}
}
}
showToast(`Scanned ${smbState.files.length} SMB video files (${cachedLoadedCount} restored from SQLite cache)`, 'success'); showToast(`Scanned ${smbState.files.length} SMB video files (${cachedLoadedCount} restored from SQLite cache)`, 'success');
} catch (err) { } catch (err) {
showToast(`SMB scan failed: ${err.message}`, 'error'); showToast(`SMB scan failed: ${err.message}`, 'error');
@@ -3141,19 +3333,38 @@ Return ONLY a valid JSON object with an array named "results":
btnSmbStopAnalysis.textContent = '⛔ Stop Analysis'; btnSmbStopAnalysis.textContent = '⛔ Stop Analysis';
btnSmbAnalyzeSelected.disabled = true; btnSmbAnalyzeSelected.disabled = true;
btnSmbAnalyzeAll.disabled = true; btnSmbAnalyzeAll.disabled = true;
if (btnSmbStashLookup) btnSmbStashLookup.disabled = true;
smbProgressBarContainer.style.display = 'block'; smbProgressBarContainer.style.display = 'block';
smbProgressBarFill.style.width = '0%'; smbProgressBarFill.style.width = '0%';
// 1. Try matching with Stash first to save AI quota
const unanalyzedBefore = filesList.filter(f => !f.analysis);
if (unanalyzedBefore.length > 0) {
const stashMatched = await matchItemsWithStash(unanalyzedBefore, 'smb');
if (stashMatched > 0) {
showToast(`⚡ Stash instantly matched ${stashMatched} file(s)!`, 'success');
}
}
const pendingFiles = filesList.filter(f => !f.analysis);
if (pendingFiles.length === 0) {
smbState.isAnalyzing = false;
btnSmbStopAnalysis.style.display = 'none';
setTimeout(() => { smbProgressBarContainer.style.display = 'none'; }, 1000);
updateSmbStats();
return;
}
const delayMs = parseInt(rateLimitDelaySelect ? rateLimitDelaySelect.value : '1500', 10) || 1500; const delayMs = parseInt(rateLimitDelaySelect ? rateLimitDelaySelect.value : '1500', 10) || 1500;
const batchSize = parseInt(aiBatchSizeSelect ? aiBatchSizeSelect.value : '5', 10) || 1; const batchSize = parseInt(aiBatchSizeSelect ? aiBatchSizeSelect.value : '5', 10) || 1;
let completed = 0; let completed = 0;
const total = filesList.length; const total = pendingFiles.length;
const chunks = []; const chunks = [];
for (let i = 0; i < filesList.length; i += batchSize) { for (let i = 0; i < pendingFiles.length; i += batchSize) {
chunks.push(filesList.slice(i, i + batchSize)); chunks.push(pendingFiles.slice(i, i + batchSize));
} }
for (let cIdx = 0; cIdx < chunks.length; cIdx++) { for (let cIdx = 0; cIdx < chunks.length; cIdx++) {
@@ -3251,6 +3462,22 @@ Return ONLY a valid JSON object with an array named "results":
runAnalysisOnSmbFiles(unanalyzed); runAnalysisOnSmbFiles(unanalyzed);
}); });
if (btnSmbStashLookup) {
btnSmbStashLookup.addEventListener('click', async () => {
const selected = smbState.files.filter(f => smbState.selectedPaths.has(f.path));
if (selected.length === 0) {
showToast('Please select at least one file to match with Stash', 'error');
return;
}
btnSmbStashLookup.disabled = true;
btnSmbStashLookup.textContent = 'Matching...';
const count = await matchItemsWithStash(selected, 'smb');
btnSmbStashLookup.disabled = false;
btnSmbStashLookup.textContent = '⚡ Stash Match (Selected)';
showToast(`Stash matched ${count} / ${selected.length} selected file(s)`, count > 0 ? 'success' : 'info');
});
}
// Relocate SMB File // Relocate SMB File
async function relocateSmbFile(fileItem) { async function relocateSmbFile(fileItem) {
if (!fileItem.targetPath) throw new Error('Target location is empty'); if (!fileItem.targetPath) throw new Error('Target location is empty');
@@ -3330,6 +3557,7 @@ Return ONLY a valid JSON object with an array named "results":
const hasSelection = smbState.selectedPaths.size > 0; const hasSelection = smbState.selectedPaths.size > 0;
const hasReadySelected = smbState.files.some(f => smbState.selectedPaths.has(f.path) && f.targetPath && f.moveStatus === 'ready'); const hasReadySelected = smbState.files.some(f => smbState.selectedPaths.has(f.path) && f.targetPath && f.moveStatus === 'ready');
if (btnSmbStashLookup) btnSmbStashLookup.disabled = !hasSelection || smbState.isAnalyzing;
btnSmbAnalyzeSelected.disabled = !hasSelection || smbState.isAnalyzing; btnSmbAnalyzeSelected.disabled = !hasSelection || smbState.isAnalyzing;
btnSmbAnalyzeAll.disabled = !hasFiles || smbState.isAnalyzing; btnSmbAnalyzeAll.disabled = !hasFiles || smbState.isAnalyzing;
btnSmbBatchRelocate.disabled = !hasReadySelected || smbState.isMoving; btnSmbBatchRelocate.disabled = !hasReadySelected || smbState.isMoving;
@@ -3675,8 +3903,9 @@ Return ONLY a valid JSON object with an array named "results":
</button>`; </button>`;
}).join(''); }).join('');
} else { } else {
pillsHtml = `<span style="font-size: 0.8rem; color: var(--text-muted);">No AI analysis performed yet:</span> pillsHtml = `<span style="font-size: 0.8rem; color: var(--text-muted);">No analysis performed yet:</span>
<button type="button" class="btn btn-secondary btn-sm" id="btn-modal-analyze-now" style="margin-left: 0.5rem;">⚡ Run AI Analysis</button>`; <button type="button" class="btn btn-secondary btn-sm" id="btn-modal-stash-now" style="margin-left: 0.5rem;">⚡ Match Stash</button>
<button type="button" class="btn btn-secondary btn-sm" id="btn-modal-analyze-now" style="margin-left: 0.25rem;">🤖 Run AI Analysis</button>`;
} }
const destFolder = item.targetPath || (item.selectedPerformer ? (type === 'smb' ? `/mnt/isolation/videos/${item.selectedPerformer}` : `/isolation/videos/${item.selectedPerformer}`) : '(No folder selected)'); const destFolder = item.targetPath || (item.selectedPerformer ? (type === 'smb' ? `/mnt/isolation/videos/${item.selectedPerformer}` : `/isolation/videos/${item.selectedPerformer}`) : '(No folder selected)');
@@ -3756,6 +3985,31 @@ Return ONLY a valid JSON object with an array named "results":
}); });
} }
const btnStashNow = videoPerformerBox.querySelector('#btn-modal-stash-now');
if (btnStashNow) {
btnStashNow.addEventListener('click', async () => {
btnStashNow.disabled = true;
btnStashNow.textContent = 'Matching...';
try {
const count = await matchItemsWithStash([item], type);
if (count > 0) {
showToast(`Matched with Stash: ${item.selectedPerformer}`, 'success');
if (type === 'smb') renderSmbRow(item.path);
else renderRow(item.hash);
renderVideoModalPerformerControls(item, type);
} else {
showToast('No match found in Stash for this video', 'info');
btnStashNow.disabled = false;
btnStashNow.textContent = '⚡ Match Stash';
}
} catch (e) {
showToast(`Stash lookup error: ${e.message}`, 'error');
btnStashNow.disabled = false;
btnStashNow.textContent = '⚡ Match Stash';
}
});
}
const btnAnalyzeNow = videoPerformerBox.querySelector('#btn-modal-analyze-now'); const btnAnalyzeNow = videoPerformerBox.querySelector('#btn-modal-analyze-now');
if (btnAnalyzeNow) { if (btnAnalyzeNow) {
btnAnalyzeNow.addEventListener('click', async () => { btnAnalyzeNow.addEventListener('click', async () => {
@@ -3865,6 +4119,7 @@ Return ONLY a valid JSON object with an array named "results":
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
loadConfig(); loadConfig();
await loadDatabaseCache(); await loadDatabaseCache();
await checkStashStatus();
await loadExistingPerformers(); await loadExistingPerformers();
if (window.location.protocol.startsWith('http')) { if (window.location.protocol.startsWith('http')) {
try { try {
+300
View File
@@ -66,6 +66,208 @@ def get_db_connection():
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
# Stash Server Integration
DEFAULT_STASH_URL = "http://servervm.local:9999/graphql"
DEFAULT_STASH_SQLITE = "/mnt/stash_appdata/stashapp/config/stash-go.sqlite"
def normalize_stash_url(url_str):
if not url_str:
return DEFAULT_STASH_URL
url_str = url_str.strip().rstrip('/')
if not url_str.startswith('http://') and not url_str.startswith('https://'):
url_str = 'http://' + url_str
if not url_str.endswith('/graphql'):
url_str = url_str + '/graphql'
if 'servervm.local' in url_str:
try:
ip = socket.gethostbyname('servervm.local')
url_str = url_str.replace('servervm.local', ip)
except Exception:
pass
return url_str
def format_performer_slug(raw_name):
if not raw_name:
return ''
clean = re.sub(r'[/\\?%*:|"<>]+', '', raw_name.strip()).lower()
parts = [p for p in re.split(r'[\s_\-\.]+', clean) if p]
return '.'.join(parts)
def execute_stash_graphql(query_str, stash_url=None, timeout=4):
endpoint = normalize_stash_url(stash_url)
req = urllib.request.Request(
endpoint,
data=json.dumps({'query': query_str}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=timeout) as res:
return json.loads(res.read().decode())
def parse_stash_scene_data(scene, match_strategy='exact_path'):
if not scene:
return None
performers = scene.get('performers', [])
if not performers:
return None
females = [p['name'] for p in performers if p.get('gender') == 'FEMALE']
males = [p['name'] for p in performers if p.get('gender') == 'MALE']
others = [p['name'] for p in performers if p.get('gender') not in ('FEMALE', 'MALE')]
# Priority: Females first as primary, then others, then males
ordered_names = females + others + males
if not ordered_names:
ordered_names = [p['name'] for p in performers]
formatted_candidates = []
seen = set()
for name in ordered_names:
fmt = format_performer_slug(name)
if fmt and fmt not in seen:
seen.add(fmt)
formatted_candidates.append({'raw': name, 'formatted': fmt})
if not formatted_candidates:
return None
primary_formatted = formatted_candidates[0]['formatted']
studio_name = scene.get('studio', {}).get('name') if scene.get('studio') else None
title = scene.get('title') or ''
scene_id = scene.get('id') or ''
reasoning = f'Stash Verified: Scene #{scene_id}'
if title:
reasoning += f' "{title}"'
if studio_name:
reasoning += f' ({studio_name})'
return {
'stash_id': scene_id,
'title': title,
'studio': studio_name,
'primaryPerformer': primary_formatted,
'allPerformers': formatted_candidates,
'femalePerformers': [format_performer_slug(n) for n in females],
'malePerformers': [format_performer_slug(n) for n in males],
'confidence': 'verified',
'reasoning': reasoning,
'matchStrategy': match_strategy
}
def query_stash_single(file_name, file_path=None, item_type='smb', stash_url=None):
basename = os.path.basename(file_path or file_name)
stash_paths = [
f'/data/videodownloader/{basename}',
f'/data/{basename}'
]
# 1. Exact path match in Stash
for p in stash_paths:
q = f'''
{{
findScenes(scene_filter: {{path: {{value: {json.dumps(p)}, modifier: EQUALS}}}}) {{
scenes {{
id
title
performers {{
name
gender
}}
studio {{
name
}}
}}
}}
}}
'''
try:
res = execute_stash_graphql(q, stash_url, timeout=3)
scenes = res.get('data', {}).get('findScenes', {}).get('scenes', [])
if scenes and scenes[0].get('performers'):
parsed = parse_stash_scene_data(scenes[0], 'exact_path')
if parsed:
return parsed
except Exception:
pass
# 2. Basename search in Stash
stem = os.path.splitext(basename)[0]
q = f'''
{{
findScenes(filter: {{q: {json.dumps(stem[:35])}, per_page: 3}}) {{
scenes {{
id
title
files {{
basename
}}
performers {{
name
gender
}}
studio {{
name
}}
}}
}}
}}
'''
try:
res = execute_stash_graphql(q, stash_url, timeout=3)
scenes = res.get('data', {}).get('findScenes', {}).get('scenes', [])
if scenes:
for s in scenes:
if s.get('performers'):
parsed = parse_stash_scene_data(s, 'fuzzy_match')
if parsed:
return parsed
except Exception:
pass
return None
def query_stash_batch(items, stash_url=None):
if not items:
return []
queries = []
for idx, item in enumerate(items):
file_path = item.get('path') or item.get('name')
basename = os.path.basename(file_path)
p = f'/data/videodownloader/{basename}'
queries.append(f'item_{idx}: findScenes(scene_filter: {{path: {{value: {json.dumps(p)}, modifier: EQUALS}}}}) {{ scenes {{ id title performers {{ name gender }} studio {{ name }} }} }}')
batch_q = '{\n' + '\n'.join(queries) + '\n}'
results = [None] * len(items)
unmatched_indices = []
try:
res = execute_stash_graphql(batch_q, stash_url, timeout=6)
data = res.get('data', {})
for idx in range(len(items)):
scenes = data.get(f'item_{idx}', {}).get('scenes', [])
if scenes and scenes[0].get('performers'):
parsed = parse_stash_scene_data(scenes[0], 'exact_path')
if parsed:
results[idx] = parsed
if not results[idx]:
unmatched_indices.append(idx)
except Exception as e:
print(f"Batch exact Stash query failed: {e}")
unmatched_indices = list(range(len(items)))
# Second pass for unmatched items
for idx in unmatched_indices:
item = items[idx]
file_name = item.get('name', '')
file_path = item.get('path', '')
item_type = item.get('item_type', 'smb')
match = query_stash_single(file_name, file_path, item_type, stash_url)
results[idx] = match
return results
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
@@ -144,6 +346,9 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
# Existing Performer Directories Intellisense API # Existing Performer Directories Intellisense API
elif self.path.startswith('/api/performers'): elif self.path.startswith('/api/performers'):
self.handle_list_performers() self.handle_list_performers()
# Stash Status API
elif self.path.startswith('/api/stash/status'):
self.handle_stash_status()
# 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()
@@ -165,6 +370,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
"target": DEFAULT_QB_HOST, "target": DEFAULT_QB_HOST,
"has_sid": bool(latest_sid), "has_sid": bool(latest_sid),
"smb_mounted": os.path.exists(DEFAULT_SMB_SOURCE), "smb_mounted": os.path.exists(DEFAULT_SMB_SOURCE),
"stash_url": DEFAULT_STASH_URL,
"db": os.path.exists(DB_PATH) "db": os.path.exists(DB_PATH)
} }
self.wfile.write(json.dumps(status_data).encode('utf-8')) self.wfile.write(json.dumps(status_data).encode('utf-8'))
@@ -180,6 +386,11 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_db_save() self.handle_db_save()
elif self.path == '/api/db/batch-save': elif self.path == '/api/db/batch-save':
self.handle_db_batch_save() self.handle_db_batch_save()
# Stash Lookup APIs
elif self.path == '/api/stash/lookup':
self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup()
# SMB API Moves # SMB API Moves
elif self.path == '/api/smb/move': elif self.path == '/api/smb/move':
self.handle_smb_move() self.handle_smb_move()
@@ -498,6 +709,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e: except Exception as e:
print(f"Error merging performers from DB: {e}") print(f"Error merging performers from DB: {e}")
# Also merge performers from Stash database if online
try:
stash_q = '{ allPerformers { name } }'
res = execute_stash_graphql(stash_q, timeout=2)
for p in res.get('data', {}).get('allPerformers', []):
slug = format_performer_slug(p.get('name'))
if slug:
performers_set.add(slug)
except Exception as e:
pass
performers = sorted(list(performers_set), key=lambda s: s.lower()) performers = sorted(list(performers_set), key=lambda s: s.lower())
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', 'application/json') self.send_header('Content-Type', 'application/json')
@@ -505,6 +727,84 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({"performers": performers, "count": len(performers)}).encode('utf-8')) self.wfile.write(json.dumps({"performers": performers, "count": len(performers)}).encode('utf-8'))
# ==================== Stash Server API Handlers ====================
def handle_stash_status(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('url', [''])[0] or DEFAULT_STASH_URL
try:
q = '{ version { version } stats { scene_count performer_count } }'
res = execute_stash_graphql(q, stash_url, timeout=3)
data = res.get('data', {})
version = data.get('version', {}).get('version', 'unknown')
scene_count = data.get('stats', {}).get('scene_count', 0)
performer_count = data.get('stats', {}).get('performer_count', 0)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({
"online": True,
"version": version,
"sceneCount": scene_count,
"performerCount": performer_count,
"url": normalize_stash_url(stash_url)
}).encode('utf-8'))
except Exception as e:
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({
"online": False,
"error": str(e),
"url": normalize_stash_url(stash_url)
}).encode('utf-8'))
def handle_stash_lookup(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
req_data = json.loads(body)
file_name = req_data.get('name', '')
file_path = req_data.get('path', '')
item_type = req_data.get('item_type', 'smb')
stash_url = req_data.get('stash_url', '')
match = query_stash_single(file_name, file_path, item_type, stash_url)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"match": match, "found": bool(match)}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "match": None, "found": False}).encode('utf-8'))
def handle_stash_batch_lookup(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
req_data = json.loads(body)
items = req_data.get('items', [])
stash_url = req_data.get('stash_url', '')
results = query_stash_batch(items, stash_url)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"results": results, "count": len(results)}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": str(e), "results": []}).encode('utf-8'))
# ==================== 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()