feat: Add Tab 5 for Stash Task & Tool Control (Start Scan, Identify Scenes, Clean, Generate) with live Job Queue Monitor

This commit is contained in:
david
2026-08-18 08:45:52 -04:00
parent 1a5cbc3e47
commit e354b52652
2 changed files with 788 additions and 5 deletions
+558 -2
View File
@@ -1119,6 +1119,11 @@
<span>Relocation History & Analytics</span> <span>Relocation History & Analytics</span>
<span class="tab-badge" id="history-tab-badge" style="background: rgba(168, 85, 247, 0.2); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.4);">0</span> <span class="tab-badge" id="history-tab-badge" style="background: rgba(168, 85, 247, 0.2); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.4);">0</span>
</button> </button>
<button class="tab-btn" id="tab-btn-stash-tasks">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span>Stash Tasks & Tool Control</span>
<span class="tab-badge" id="stash-tasks-tab-badge" style="background: rgba(16, 185, 129, 0.2); color: #6ee7b7; border-color: rgba(16, 185, 129, 0.4);">Ready</span>
</button>
</div> </div>
<!-- Unified Configuration Card (Collapsible - Collapsed by default) --> <!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
@@ -1748,6 +1753,227 @@
</div> </div>
</div> </div>
</div> </div>
<!-- ==================== TAB 5: Stash Tasks & Tool Control ==================== -->
<div class="tab-panel" id="tab-panel-stash-tasks">
<!-- Live Task Queue & Job Monitor Card -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem; border-color: rgba(16, 185, 129, 0.4); background: rgba(15, 23, 42, 0.75);">
<div class="table-header-bar" style="margin-bottom: 0.75rem;">
<div class="table-title" style="display: flex; align-items: center; gap: 0.5rem; color: var(--accent-green);">
<span class="status-indicator online" id="stash-task-pulse-dot" style="background: var(--accent-green);"></span>
<span>⚡ Stash Live Job Queue & Task Monitor</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-secondary" id="btn-refresh-stash-jobs" style="padding: 0.25rem 0.6rem; font-size: 0.75rem;">
🔄 Refresh Jobs
</button>
<button class="btn btn-secondary" id="btn-stop-all-stash-jobs" style="padding: 0.25rem 0.6rem; font-size: 0.75rem; color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Cancel and abort all active/queued Stash background tasks">
⛔ Stop All Jobs
</button>
</div>
</div>
<div id="stash-active-jobs-container">
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 0.5rem 0;">
No background tasks currently running in Stash. Ready to start tasks below.
</div>
</div>
</div>
<!-- 4 Core Tool Modules Grid -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
<!-- CARD 1: Start Scan -->
<div class="table-container" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 0.6rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">
<span style="font-size: 1.3rem;">🚀</span>
<div>
<h3 style="font-size: 0.95rem; font-weight: 700; color: #fff;">1. Start Library Scan</h3>
<p style="font-size: 0.72rem; color: var(--text-muted);">Detect new scenes, files, and update database</p>
</div>
</div>
<div class="form-group">
<label class="form-label" for="stash-scan-path">Target Scan Path(s)</label>
<input type="text" class="form-control" id="stash-scan-path" value="/isolation/videos" placeholder="Leave empty for all library paths or e.g. /isolation/videos" />
<span style="font-size: 0.68rem; color: var(--text-muted);">Path relative to Stash server</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-scan-opt-rescan" />
<span>Force Rescan All</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-scan-opt-covers" checked />
<span>Generate Covers</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-scan-opt-previews" />
<span>Generate Previews</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-scan-opt-sprites" />
<span>Generate Sprites</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-scan-opt-phashes" checked />
<span>Generate pHashes</span>
</label>
</div>
<div style="margin-top: auto; padding-top: 0.5rem;">
<button class="btn btn-primary" id="btn-exec-stash-scan" style="width: 100%; justify-content: center; font-weight: 600;">
🚀 Start Library Scan
</button>
</div>
</div>
<!-- CARD 2: Identify Scenes -->
<div class="table-container" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 0.6rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">
<span style="font-size: 1.3rem;">🔍</span>
<div>
<h3 style="font-size: 0.95rem; font-weight: 700; color: #fff;">2. Identify Scenes</h3>
<p style="font-size: 0.72rem; color: var(--text-muted);">Scrape StashDB, match titles, tag performers & studios</p>
</div>
</div>
<div class="form-group">
<label class="form-label" for="stash-identify-mode">Identification Engine</label>
<select class="form-control" id="stash-identify-mode">
<option value="autotag">⚡ Auto-Tag (Filename & Metadata Tagger)</option>
<option value="identify">🌐 StashDB Scraper Identify (Fingerprint / pHash)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="stash-identify-path">Target Path(s)</label>
<input type="text" class="form-control" id="stash-identify-path" value="/isolation/videos" placeholder="Leave empty for all paths or e.g. /isolation/videos" />
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-identify-opt-performers" checked />
<span>Tag Performers</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-identify-opt-studios" checked />
<span>Tag Studios</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-identify-opt-tags" checked />
<span>Tag Categories</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-identify-opt-covers" checked />
<span>Fetch Covers</span>
</label>
</div>
<div style="margin-top: auto; padding-top: 0.5rem;">
<button class="btn btn-primary" id="btn-exec-stash-identify" style="width: 100%; justify-content: center; font-weight: 600; background: linear-gradient(135deg, #0284c7, #2563eb);">
🔍 Start Scene Identification
</button>
</div>
</div>
<!-- CARD 3: Clean -->
<div class="table-container" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 0.6rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">
<span style="font-size: 1.3rem;">🧹</span>
<div>
<h3 style="font-size: 0.95rem; font-weight: 700; color: #fff;">3. Clean Metadata & Content</h3>
<p style="font-size: 0.72rem; color: var(--text-muted);">Prune missing database files & orphaned generated caches</p>
</div>
</div>
<div class="form-group">
<label class="form-label" for="stash-clean-type">Clean Operation Scope</label>
<select class="form-control" id="stash-clean-type">
<option value="metadata">📁 Clean Missing Files (Remove DB entries with missing disk files)</option>
<option value="generated">🎞️ Clean Orphaned Generated Content (Thumbnails/Sprites/Transcodes)</option>
<option value="both">⚡ Clean Both (Missing Files + Orphaned Generated Content)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="stash-clean-path">Path Filter</label>
<input type="text" class="form-control" id="stash-clean-path" placeholder="Optional: Path to clean or leave empty for all" />
</div>
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
<label style="font-size: 0.75rem; color: var(--accent-amber); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font-weight: 600;">
<input type="checkbox" id="stash-clean-opt-dryrun" />
<span>🧪 Dry Run Mode (Simulate without deleting records)</span>
</label>
</div>
<div style="margin-top: auto; padding-top: 0.5rem;">
<button class="btn btn-secondary" id="btn-exec-stash-clean" style="width: 100%; justify-content: center; font-weight: 600; color: var(--accent-amber); border-color: rgba(245, 158, 11, 0.4);">
🧹 Execute Clean Task
</button>
</div>
</div>
<!-- CARD 4: Generate -->
<div class="table-container" style="padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; align-items: center; gap: 0.6rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">
<span style="font-size: 1.3rem;">⚡</span>
<div>
<h3 style="font-size: 0.95rem; font-weight: 700; color: #fff;">4. Generate Media & Previews</h3>
<p style="font-size: 0.72rem; color: var(--text-muted);">Batch create scrubber previews, sprites, markers & transcodes</p>
</div>
</div>
<div class="form-group">
<label class="form-label" for="stash-generate-path">Target Path(s)</label>
<input type="text" class="form-control" id="stash-generate-path" value="/isolation/videos" placeholder="Leave empty for all paths or e.g. /isolation/videos" />
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-covers" checked />
<span>🖼️ Covers</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-previews" checked />
<span>🎞️ Video Previews</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-sprites" checked />
<span>📸 Sprites</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-markers" checked />
<span>🏷️ Markers & Snaps</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-phashes" checked />
<span>🔢 pHashes</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-transcodes" />
<span>🎬 Transcodes</span>
</label>
</div>
<div>
<label style="font-size: 0.75rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="stash-gen-opt-overwrite" />
<span>Overwrite existing generated assets</span>
</label>
</div>
<div style="margin-top: auto; padding-top: 0.5rem;">
<button class="btn btn-success" id="btn-exec-stash-generate" style="width: 100%; justify-content: center; font-weight: 600;">
⚡ Start Generation Task
</button>
</div>
</div>
</div>
</div>
</main> </main>
<!-- Command Palette Modal (Ctrl + K) --> <!-- Command Palette Modal (Ctrl + K) -->
@@ -2230,7 +2456,7 @@
] ]
}; };
// DOM Elements - Tab 3 Stash Images & Tab 4 History // DOM Elements - Tab 3 Stash Images, Tab 4 History & Tab 5 Stash Tasks
const tabBtnStashImages = document.getElementById('tab-btn-stash-images'); const tabBtnStashImages = document.getElementById('tab-btn-stash-images');
const tabPanelStashImages = document.getElementById('tab-panel-stash-images'); const tabPanelStashImages = document.getElementById('tab-panel-stash-images');
const stashImagesTabBadge = document.getElementById('stash-images-tab-badge'); const stashImagesTabBadge = document.getElementById('stash-images-tab-badge');
@@ -2239,23 +2465,43 @@
const tabPanelHistory = document.getElementById('tab-panel-history'); const tabPanelHistory = document.getElementById('tab-panel-history');
const historyTabBadge = document.getElementById('history-tab-badge'); const historyTabBadge = document.getElementById('history-tab-badge');
const tabBtnStashTasks = document.getElementById('tab-btn-stash-tasks');
const tabPanelStashTasks = document.getElementById('tab-panel-stash-tasks');
const stashTasksTabBadge = document.getElementById('stash-tasks-tab-badge');
let stashJobsPollingInterval = null;
// Tab Switching Logic // Tab Switching Logic
function switchTab(tabName) { function switchTab(tabName) {
tabBtnQb.classList.toggle('active', tabName === 'qb'); tabBtnQb.classList.toggle('active', tabName === 'qb');
tabBtnSmb.classList.toggle('active', tabName === 'smb'); tabBtnSmb.classList.toggle('active', tabName === 'smb');
if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images'); if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images');
if (tabBtnHistory) tabBtnHistory.classList.toggle('active', tabName === 'history'); if (tabBtnHistory) tabBtnHistory.classList.toggle('active', tabName === 'history');
if (tabBtnStashTasks) tabBtnStashTasks.classList.toggle('active', tabName === 'stash-tasks');
tabPanelQb.classList.toggle('active', tabName === 'qb'); tabPanelQb.classList.toggle('active', tabName === 'qb');
tabPanelSmb.classList.toggle('active', tabName === 'smb'); tabPanelSmb.classList.toggle('active', tabName === 'smb');
if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images'); if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images');
if (tabPanelHistory) tabPanelHistory.classList.toggle('active', tabName === 'history'); if (tabPanelHistory) tabPanelHistory.classList.toggle('active', tabName === 'history');
if (tabPanelStashTasks) tabPanelStashTasks.classList.toggle('active', tabName === 'stash-tasks');
if (tabName === 'history') { if (tabName === 'history') {
loadRelocationHistory(); loadRelocationHistory();
loadStorageAnalytics(); loadStorageAnalytics();
} }
if (tabName === 'stash-tasks') {
pollStashJobs();
if (!stashJobsPollingInterval) {
stashJobsPollingInterval = setInterval(pollStashJobs, 3000);
}
} else {
if (stashJobsPollingInterval) {
clearInterval(stashJobsPollingInterval);
stashJobsPollingInterval = null;
}
}
localStorage.setItem('qb_active_tab', tabName); localStorage.setItem('qb_active_tab', tabName);
} }
@@ -2263,6 +2509,7 @@
tabBtnSmb.addEventListener('click', () => switchTab('smb')); tabBtnSmb.addEventListener('click', () => switchTab('smb'));
if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images')); if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images'));
if (tabBtnHistory) tabBtnHistory.addEventListener('click', () => switchTab('history')); if (tabBtnHistory) tabBtnHistory.addEventListener('click', () => switchTab('history'));
if (tabBtnStashTasks) tabBtnStashTasks.addEventListener('click', () => switchTab('stash-tasks'));
function getProviderDisplayName() { function getProviderDisplayName() {
const provider = aiProviderSelect ? aiProviderSelect.value : 'local'; const provider = aiProviderSelect ? aiProviderSelect.value : 'local';
@@ -6209,6 +6456,310 @@ Return ONLY a valid JSON object with an array named "results":
}); });
} }
// ==================== TAB 5: STASH TASKS & TOOL CONTROL ====================
let activeStashJobs = [];
async function pollStashJobs() {
try {
const res = await fetch('/api/stash/tasks/jobs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
activeStashJobs = data.jobs || [];
renderStashJobs(activeStashJobs);
} catch (err) {
console.warn('Failed polling Stash jobs:', err);
}
}
function renderStashJobs(jobs) {
const container = document.getElementById('stash-active-jobs-container');
const badge = document.getElementById('stash-tasks-tab-badge');
const pulseDot = document.getElementById('stash-task-pulse-dot');
const runningJobs = jobs.filter(j => j.status === 'RUNNING' || j.status === 'QUEUED' || j.status === 'STOPPING');
if (badge) {
if (runningJobs.length > 0) {
badge.textContent = `${runningJobs.length} Running`;
badge.style.background = 'rgba(6, 182, 212, 0.2)';
badge.style.color = '#67e8f9';
badge.style.borderColor = 'rgba(6, 182, 212, 0.4)';
if (pulseDot) pulseDot.style.background = 'var(--accent-cyan)';
} else {
badge.textContent = 'Ready';
badge.style.background = 'rgba(16, 185, 129, 0.2)';
badge.style.color = '#6ee7b7';
badge.style.borderColor = 'rgba(16, 185, 129, 0.4)';
if (pulseDot) pulseDot.style.background = 'var(--accent-green)';
}
}
if (!container) return;
if (!jobs || jobs.length === 0) {
container.innerHTML = `
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 0.5rem 0;">
No background tasks currently active in Stash. Select any tool below to launch.
</div>
`;
return;
}
container.innerHTML = jobs.map(job => {
const isRunning = job.status === 'RUNNING';
const isQueued = job.status === 'QUEUED';
const isStopping = job.status === 'STOPPING';
const isFinished = job.status === 'FINISHED';
const isFailed = job.status === 'FAILED';
const isCancelled = job.status === 'CANCELLED';
const pct = Math.round((job.progress || 0) * 100);
const subTasksText = (job.subTasks && job.subTasks.length) ? job.subTasks.join(' • ') : '';
let badgeClass = 'primary';
if (isFinished) badgeClass = 'success';
if (isFailed) badgeClass = 'danger';
if (isStopping || isCancelled) badgeClass = 'amber';
return `
<div style="background: rgba(30, 41, 59, 0.7); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.85rem; margin-bottom: 0.6rem; display: flex; flex-direction: column; gap: 0.45rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.1rem;">${isRunning ? '⚙️' : isFinished ? '✅' : isFailed ? '❌' : '⏳'}</span>
<span style="font-weight: 600; color: #fff; font-size: 0.85rem;">${escapeHtml(job.description || 'Stash Task')}</span>
<span class="badge badge-${badgeClass}" style="font-size: 0.68rem; text-transform: uppercase;">${job.status}</span>
</div>
<div style="display: flex; align-items: center; gap: 0.6rem;">
<span style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--accent-cyan);">${pct}%</span>
${(isRunning || isQueued) ? `
<button class="btn btn-secondary btn-stop-single-job" data-job-id="${job.id}" style="padding: 0.2rem 0.5rem; font-size: 0.7rem; color: var(--accent-rose); border-color: rgba(244,63,94,0.4);" title="Cancel this job">
⛔ Stop
</button>
` : ''}
</div>
</div>
<!-- Progress Bar -->
<div style="background: rgba(0,0,0,0.35); border-radius: 999px; height: 6px; overflow: hidden; width: 100%;">
<div style="background: ${isFailed ? 'var(--accent-rose)' : isFinished ? 'var(--accent-green)' : 'linear-gradient(90deg, var(--accent-cyan), var(--accent-purple))'}; width: ${pct}%; height: 100%; transition: width 0.3s ease;"></div>
</div>
${subTasksText ? `<div style="font-size: 0.7rem; color: var(--text-muted); font-family: var(--font-mono);">${escapeHtml(subTasksText)}</div>` : ''}
${job.error ? `<div style="font-size: 0.72rem; color: var(--accent-rose);">${escapeHtml(job.error)}</div>` : ''}
</div>
`;
}).join('');
container.querySelectorAll('.btn-stop-single-job').forEach(btn => {
btn.addEventListener('click', async () => {
const jid = btn.getAttribute('data-job-id');
await executeStashStopJob(jid);
});
});
}
async function executeStashScanTask() {
const btn = document.getElementById('btn-exec-stash-scan');
const pathVal = document.getElementById('stash-scan-path')?.value.trim();
const rescan = document.getElementById('stash-scan-opt-rescan')?.checked;
const covers = document.getElementById('stash-scan-opt-covers')?.checked;
const previews = document.getElementById('stash-scan-opt-previews')?.checked;
const sprites = document.getElementById('stash-scan-opt-sprites')?.checked;
const phashes = document.getElementById('stash-scan-opt-phashes')?.checked;
const paths = pathVal ? [pathVal] : [];
try {
btn.disabled = true;
btn.textContent = 'Launching Scan...';
const res = await fetch('/api/stash/tasks/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths,
rescan,
scanGenerateCovers: covers,
scanGeneratePreviews: previews,
scanGenerateSprites: sprites,
scanGeneratePhashes: phashes
})
});
const data = await res.json();
if (data.success) {
showToast('🚀 Stash library scan launched successfully!', 'success');
await pollStashJobs();
} else {
showToast(`Scan launch failed: ${data.error}`, 'error');
}
} catch (err) {
showToast(`Scan error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🚀 Start Library Scan';
}
}
async function executeStashIdentifyTask() {
const btn = document.getElementById('btn-exec-stash-identify');
const mode = document.getElementById('stash-identify-mode')?.value || 'autotag';
const pathVal = document.getElementById('stash-identify-path')?.value.trim();
const performers = document.getElementById('stash-identify-opt-performers')?.checked;
const studios = document.getElementById('stash-identify-opt-studios')?.checked;
const tags = document.getElementById('stash-identify-opt-tags')?.checked;
const paths = pathVal ? [pathVal] : [];
try {
btn.disabled = true;
btn.textContent = 'Launching Identify...';
const res = await fetch('/api/stash/tasks/identify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode,
paths,
performers,
studios,
tags
})
});
const data = await res.json();
if (data.success) {
showToast('🔍 Stash scene identify/auto-tag task launched!', 'success');
await pollStashJobs();
} else {
showToast(`Identify launch failed: ${data.error}`, 'error');
}
} catch (err) {
showToast(`Identify error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🔍 Start Scene Identification';
}
}
async function executeStashCleanTask() {
const btn = document.getElementById('btn-exec-stash-clean');
const cleanType = document.getElementById('stash-clean-type')?.value || 'metadata';
const dryRun = document.getElementById('stash-clean-opt-dryrun')?.checked;
const pathVal = document.getElementById('stash-clean-path')?.value.trim();
const paths = pathVal ? [pathVal] : [];
if (!dryRun && !confirm('Are you sure you want to run Clean? Deleted database records or generated caches cannot be undone.')) return;
try {
btn.disabled = true;
btn.textContent = 'Running Clean...';
const res = await fetch('/api/stash/tasks/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clean_type: cleanType,
dry_run: dryRun,
paths
})
});
const data = await res.json();
if (data.success) {
showToast(`🧹 Stash Clean task launched ${dryRun ? '(DRY RUN mode)' : ''}!`, 'success');
await pollStashJobs();
} else {
showToast(`Clean launch failed: ${data.error}`, 'error');
}
} catch (err) {
showToast(`Clean error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🧹 Execute Clean Task';
}
}
async function executeStashGenerateTask() {
const btn = document.getElementById('btn-exec-stash-generate');
const pathVal = document.getElementById('stash-generate-path')?.value.trim();
const covers = document.getElementById('stash-gen-opt-covers')?.checked;
const previews = document.getElementById('stash-gen-opt-previews')?.checked;
const sprites = document.getElementById('stash-gen-opt-sprites')?.checked;
const markers = document.getElementById('stash-gen-opt-markers')?.checked;
const phashes = document.getElementById('stash-gen-opt-phashes')?.checked;
const transcodes = document.getElementById('stash-gen-opt-transcodes')?.checked;
const overwrite = document.getElementById('stash-gen-opt-overwrite')?.checked;
const paths = pathVal ? [pathVal] : [];
try {
btn.disabled = true;
btn.textContent = 'Launching Generation...';
const res = await fetch('/api/stash/tasks/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
covers,
previews,
sprites,
imagePreviews: true,
markers,
phashes,
transcodes,
overwrite,
paths
})
});
const data = await res.json();
if (data.success) {
showToast('⚡ Stash media generation task started!', 'success');
await pollStashJobs();
} else {
showToast(`Generate launch failed: ${data.error}`, 'error');
}
} catch (err) {
showToast(`Generate error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '⚡ Start Generation Task';
}
}
async function executeStashStopJob(jobId, stopAll = false) {
try {
const res = await fetch('/api/stash/tasks/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jobId, stop_all: stopAll })
});
const data = await res.json();
if (data.success) {
showToast(stopAll ? '⛔ All Stash jobs stopped.' : '⛔ Job cancellation requested.', 'info');
await pollStashJobs();
}
} catch (err) {
showToast(`Stop job error: ${err.message}`, 'error');
}
}
// Attach Tab 5 Listeners
const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs');
if (btnRefreshStashJobs) btnRefreshStashJobs.addEventListener('click', pollStashJobs);
const btnStopAllStashJobs = document.getElementById('btn-stop-all-stash-jobs');
if (btnStopAllStashJobs) {
btnStopAllStashJobs.addEventListener('click', () => {
if (confirm('Cancel and abort all active/queued Stash tasks?')) {
executeStashStopJob(null, true);
}
});
}
const btnExecStashScan = document.getElementById('btn-exec-stash-scan');
if (btnExecStashScan) btnExecStashScan.addEventListener('click', executeStashScanTask);
const btnExecStashIdentify = document.getElementById('btn-exec-stash-identify');
if (btnExecStashIdentify) btnExecStashIdentify.addEventListener('click', executeStashIdentifyTask);
const btnExecStashClean = document.getElementById('btn-exec-stash-clean');
if (btnExecStashClean) btnExecStashClean.addEventListener('click', executeStashCleanTask);
const btnExecStashGenerate = document.getElementById('btn-exec-stash-generate');
if (btnExecStashGenerate) btnExecStashGenerate.addEventListener('click', executeStashGenerateTask);
// ==================== COMMAND PALETTE (CTRL + K) ==================== // ==================== COMMAND PALETTE (CTRL + K) ====================
const cmdPaletteModal = document.getElementById('cmd-palette-modal'); const cmdPaletteModal = document.getElementById('cmd-palette-modal');
const cmdPaletteInput = document.getElementById('cmd-palette-input'); const cmdPaletteInput = document.getElementById('cmd-palette-input');
@@ -6219,10 +6770,14 @@ Return ONLY a valid JSON object with an array named "results":
{ title: 'Switch to SMB Video Sorter', icon: '📂', action: () => switchTab('smb') }, { title: 'Switch to SMB Video Sorter', icon: '📂', action: () => switchTab('smb') },
{ title: 'Switch to Stash Image Review', icon: '🖼️', action: () => switchTab('stash-images') }, { title: 'Switch to Stash Image Review', icon: '🖼️', action: () => switchTab('stash-images') },
{ title: 'Switch to Relocation History & Analytics', icon: '📜', action: () => switchTab('history') }, { title: 'Switch to Relocation History & Analytics', icon: '📜', action: () => switchTab('history') },
{ title: 'Switch to Stash Tasks & Tool Control', icon: '⚡', action: () => switchTab('stash-tasks') },
{ title: 'Stash: Start Library Scan', icon: '🚀', action: () => { switchTab('stash-tasks'); executeStashScanTask(); } },
{ title: 'Stash: Identify & Auto-Tag Scenes', icon: '🔍', action: () => { switchTab('stash-tasks'); executeStashIdentifyTask(); } },
{ title: 'Stash: Clean Database & Generated Files', icon: '🧹', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } },
{ title: 'Stash: Generate Previews & Sprites', icon: '⚡', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } },
{ title: 'Scan SMB Files (/videodownloader)', icon: '🔍', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } }, { title: 'Scan SMB Files (/videodownloader)', icon: '🔍', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } },
{ title: 'Scan Stash Missing Performer Images', icon: '⚡', action: () => { switchTab('stash-images'); document.getElementById('btn-scan-stash-images')?.click(); } }, { title: 'Scan Stash Missing Performer Images', icon: '⚡', action: () => { switchTab('stash-images'); document.getElementById('btn-scan-stash-images')?.click(); } },
{ title: 'Scan Zero-Byte & Empty Folders', icon: '🧹', action: () => scanSmbCleanup() }, { title: 'Scan Zero-Byte & Empty Folders', icon: '🧹', action: () => scanSmbCleanup() },
{ title: 'Trigger Stash Library Metadata Scan', icon: '⚡', action: () => triggerStashLibraryScan() },
{ title: 'Export qBittorrent Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-qb-plan')?.click() }, { title: 'Export qBittorrent Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-qb-plan')?.click() },
{ title: 'Export SMB Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-smb-plan')?.click() } { title: 'Export SMB Plan to CSV', icon: '📥', action: () => document.getElementById('btn-export-smb-plan')?.click() }
]; ];
@@ -6309,6 +6864,7 @@ Return ONLY a valid JSON object with an array named "results":
await checkStashStatus(); await checkStashStatus();
await loadExistingPerformers(); await loadExistingPerformers();
loadRelocationHistory(); loadRelocationHistory();
pollStashJobs();
if (window.location.protocol.startsWith('http')) { if (window.location.protocol.startsWith('http')) {
try { try {
await qbLogin(); await qbLogin();
+230 -3
View File
@@ -789,9 +789,11 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_performer_image() self.handle_performer_image()
elif self.path.startswith('/api/image/proxy'): elif self.path.startswith('/api/image/proxy'):
self.handle_image_proxy() self.handle_image_proxy()
# Stash Status & Performer Review APIs # Stash Status, Tasks & Performer Review APIs
elif self.path.startswith('/api/stash/status'): elif self.path.startswith('/api/stash/status'):
self.handle_stash_status() self.handle_stash_status()
elif self.path.startswith('/api/stash/tasks/jobs'):
self.handle_stash_tasks_jobs()
elif self.path.startswith('/api/stash/performers/missing-images'): elif self.path.startswith('/api/stash/performers/missing-images'):
self.handle_stash_missing_images() self.handle_stash_missing_images()
# SMB API # SMB API
@@ -836,13 +838,23 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_history_undo() self.handle_history_undo()
elif self.path == '/api/history/clear': elif self.path == '/api/history/clear':
self.handle_history_clear() self.handle_history_clear()
# Stash Lookup, Scan & Image Review APIs # Stash Lookup, Tasks, Scan & Image Review APIs
elif self.path == '/api/stash/lookup': elif self.path == '/api/stash/lookup':
self.handle_stash_lookup() self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup': elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup() self.handle_stash_batch_lookup()
elif self.path == '/api/stash/scan': elif self.path == '/api/stash/scan':
self.handle_stash_metadata_scan() self.handle_stash_metadata_scan()
elif self.path == '/api/stash/tasks/scan':
self.handle_stash_task_scan()
elif self.path == '/api/stash/tasks/identify':
self.handle_stash_task_identify()
elif self.path == '/api/stash/tasks/clean':
self.handle_stash_task_clean()
elif self.path == '/api/stash/tasks/generate':
self.handle_stash_task_generate()
elif self.path == '/api/stash/tasks/stop':
self.handle_stash_task_stop()
elif self.path == '/api/stash/performers/find-images': elif self.path == '/api/stash/performers/find-images':
self.handle_stash_find_images() self.handle_stash_find_images()
elif self.path == '/api/stash/performers/commit-image': elif self.path == '/api/stash/performers/commit-image':
@@ -1895,7 +1907,222 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
# ==================== Stash Library Metadata Scan Trigger ==================== # ==================== Stash Task & Tool Control Handlers ====================
def handle_stash_tasks_jobs(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
query = '''
query JobQueue {
jobQueue {
id
status
subTasks
description
progress
startTime
endTime
addTime
error
}
}
'''
try:
res = execute_stash_graphql(query, stash_url, timeout=10)
jobs = res.get('data', {}).get('jobQueue') or []
self.send_json_response(200, {"jobs": jobs, "count": len(jobs)})
except Exception as e:
self.send_json_response(500, {"error": str(e), "jobs": []})
def handle_stash_task_scan(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'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
paths = payload.get('paths', [])
rescan = payload.get('rescan', False)
scanGenerateCovers = payload.get('scanGenerateCovers', True)
scanGeneratePreviews = payload.get('scanGeneratePreviews', False)
scanGenerateSprites = payload.get('scanGenerateSprites', False)
scanGeneratePhashes = payload.get('scanGeneratePhashes', False)
mutation = '''
mutation MetadataScan($input: ScanMetadataInput!) {
metadataScan(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None,
"rescan": bool(rescan),
"scanGenerateCovers": bool(scanGenerateCovers),
"scanGeneratePreviews": bool(scanGeneratePreviews),
"scanGenerateSprites": bool(scanGenerateSprites),
"scanGeneratePhashes": bool(scanGeneratePhashes)
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('metadataScan')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_task_identify(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'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
paths = payload.get('paths', [])
mode = payload.get('mode', 'autotag')
if mode == 'autotag':
mutation = '''
mutation MetadataAutoTag($input: AutoTagMetadataInput!) {
metadataAutoTag(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None,
"performers": bool(payload.get('performers', True)),
"studios": bool(payload.get('studios', True)),
"tags": bool(payload.get('tags', True))
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')})
else:
mutation = '''
mutation MetadataIdentify($input: IdentifyMetadataInput!) {
metadataIdentify(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None,
"options": {
"setCoverImage": True,
"setOrganized": True
}
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataIdentify')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_task_clean(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'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
clean_type = payload.get('clean_type', 'metadata')
dry_run = bool(payload.get('dry_run', False))
paths = payload.get('paths', [])
results = {}
if clean_type in ['metadata', 'both']:
mutation = '''
mutation MetadataClean($input: CleanMetadataInput!) {
metadataClean(input: $input)
}
'''
variables = {
"input": {
"dryRun": dry_run,
"paths": paths if paths else None
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
results['metadataClean'] = res.get('data', {}).get('metadataClean')
if clean_type in ['generated', 'both']:
mutation_gen = '''
mutation MetadataCleanGenerated($input: CleanGeneratedInput!) {
metadataCleanGenerated(input: $input)
}
'''
variables_gen = {
"input": {
"dryRun": dry_run,
"blobFiles": True,
"sprites": True,
"screenshots": True,
"transcodes": True,
"markers": True,
"imageThumbnails": True
}
}
res_gen = execute_stash_graphql(mutation_gen, stash_url, variables=variables_gen, timeout=15)
results['metadataCleanGenerated'] = res_gen.get('data', {}).get('metadataCleanGenerated')
self.send_json_response(200, {"success": True, "results": results})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_task_generate(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'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
mutation = '''
mutation MetadataGenerate($input: GenerateMetadataInput!) {
metadataGenerate(input: $input)
}
'''
variables = {
"input": {
"covers": bool(payload.get('covers', True)),
"sprites": bool(payload.get('sprites', True)),
"previews": bool(payload.get('previews', True)),
"imagePreviews": bool(payload.get('imagePreviews', True)),
"markers": bool(payload.get('markers', True)),
"transcodes": bool(payload.get('transcodes', False)),
"phashes": bool(payload.get('phashes', True)),
"overwrite": bool(payload.get('overwrite', False)),
"paths": payload.get('paths') if payload.get('paths') else None
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataGenerate')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_task_stop(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'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
job_id = payload.get('job_id')
stop_all = payload.get('stop_all', False)
if stop_all or not job_id:
mutation = '''
mutation StopAllJobs {
stopAllJobs
}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=10)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopAllJobs')})
else:
mutation = '''
mutation StopJob($job_id: ID!) {
stopJob(job_id: $job_id)
}
'''
res = execute_stash_graphql(mutation, stash_url, variables={"job_id": str(job_id)}, timeout=10)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopJob')})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_stash_metadata_scan(self): def handle_stash_metadata_scan(self):
content_length = int(self.headers.get('Content-Length', 0)) content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length) post_data = self.rfile.read(content_length)