Enhancement: Direct Stash instance integration with scene performer matching, female prioritization & auto-lookup
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user