feat: Add real-time live terminal execution console to Stash Transcode module

This commit is contained in:
david
2026-08-18 11:43:09 -04:00
parent 87c8f94db6
commit fceeac55f8
+140 -5
View File
@@ -2246,6 +2246,32 @@
<p>Click "Scan & Correlate Transcodes" to index all generated Stash transcodes and match them to original video files.</p> <p>Click "Scan & Correlate Transcodes" to index all generated Stash transcodes and match them to original video files.</p>
</div> </div>
</div> </div>
<!-- Real-Time Transcode Execution Console / Terminal -->
<div id="transcode-terminal-wrapper" style="margin-top: 1.25rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.8rem; font-weight: 700; color: #fff; display: inline-flex; align-items: center; gap: 0.4rem;">
📟 Transcode Operation Live Terminal
</span>
<span id="transcode-term-status" class="badge" style="font-size: 0.68rem; font-family: var(--font-mono); background: rgba(255,255,255,0.06); color: var(--text-muted);">IDLE</span>
</div>
<div style="display: flex; gap: 0.4rem; align-items: center;">
<label style="font-size: 0.72rem; color: var(--text-muted); display: inline-flex; align-items: center; gap: 0.3rem; margin-right: 0.4rem; cursor: pointer;">
<input type="checkbox" id="transcode-term-autoscroll" checked /> Auto-scroll
</label>
<button class="btn btn-secondary" id="btn-clear-transcode-term" style="font-size: 0.7rem; padding: 0.2rem 0.5rem;">
🧹 Clear Log
</button>
<button class="btn btn-secondary" id="btn-copy-transcode-term" style="font-size: 0.7rem; padding: 0.2rem 0.5rem;">
📋 Copy
</button>
</div>
</div>
<div id="transcode-terminal-body" style="background: #090d16; border: 1px solid #1e293b; border-radius: var(--radius-sm); padding: 0.85rem; font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.5; color: #94a3b8; height: 180px; max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; box-shadow: inset 0 2px 6px rgba(0,0,0,0.5);">
<span style="color: #64748b;">[System] Transcode Live Execution Console ready. Start a replacement or batch scan to stream operations here...</span>
</div>
</div>
</div> </div>
</div> </div>
@@ -7789,6 +7815,11 @@ Return ONLY a valid JSON object with an array named "results":
try { try {
if (btn) { btn.disabled = true; btn.textContent = 'Correlating Transcodes...'; } if (btn) { btn.disabled = true; btn.textContent = 'Correlating Transcodes...'; }
logTranscodeTerm(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, 'info');
logTranscodeTerm(`🔍 Starting Stash Transcode Correlator scan...`, 'progress');
logTranscodeTerm(`Transcode Dir: ${transcodeDir}`, 'info');
logTranscodeTerm(`Stash DB Prefix: ${stashPrefix} ➔ Local Mount Prefix: ${localPrefix}`, 'info');
if (container) { if (container) {
container.innerHTML = ` container.innerHTML = `
<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);"> <div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">
@@ -7817,13 +7848,17 @@ Return ONLY a valid JSON object with an array named "results":
if (statMatches) statMatches.textContent = `${transcodeItems.length} matched (${data.total_transcodes_on_disk || 0} on disk)`; if (statMatches) statMatches.textContent = `${transcodeItems.length} matched (${data.total_transcodes_on_disk || 0} on disk)`;
const statSavings = document.getElementById('stat-transcode-savings'); const statSavings = document.getElementById('stat-transcode-savings');
const gb = (data.total_potential_savings_bytes || 0) / (1024**3);
if (statSavings) { if (statSavings) {
const gb = (data.total_potential_savings_bytes || 0) / (1024**3);
statSavings.textContent = `${gb.toFixed(2)} GB`; statSavings.textContent = `${gb.toFixed(2)} GB`;
} }
logTranscodeTerm(`✓ Scan Completed: Indexed ${data.total_transcodes_on_disk || 0} files on disk, matched ${transcodeItems.length} scenes.`, 'success');
logTranscodeTerm(`Total Potential Storage Savings: ${gb.toFixed(2)} GB across ${transcodeItems.filter(x => x.saved_bytes > 0).length} positive saving scenes.`, 'info');
renderStashTranscodes(); renderStashTranscodes();
} catch (err) { } catch (err) {
logTranscodeTerm(`Scan failed: ${err.message}`, 'error');
showToast(`Transcode scan error: ${err.message}`, 'error'); showToast(`Transcode scan error: ${err.message}`, 'error');
if (container) { if (container) {
container.innerHTML = `<div style="color: var(--accent-rose); padding: 1.5rem;">Failed to correlate transcodes: ${err.message}</div>`; container.innerHTML = `<div style="color: var(--accent-rose); padding: 1.5rem;">Failed to correlate transcodes: ${err.message}</div>`;
@@ -7989,13 +8024,90 @@ Return ONLY a valid JSON object with an array named "results":
if (countEl) countEl.textContent = selectedTranscodeHashes.size; if (countEl) countEl.textContent = selectedTranscodeHashes.size;
} }
// ==================== Real-Time Transcode Terminal Logger ====================
function logTranscodeTerm(message, level = 'info') {
const body = document.getElementById('transcode-terminal-body');
const statusBadge = document.getElementById('transcode-term-status');
const autoscroll = document.getElementById('transcode-term-autoscroll')?.checked;
if (!body) return;
const now = new Date();
const timeStr = now.toTimeString().split(' ')[0] + '.' + String(now.getMilliseconds()).padStart(3, '0');
let color = '#94a3b8';
let icon = 'ℹ️';
if (level === 'success') { color = '#34d399'; icon = '✅'; }
else if (level === 'warn') { color = '#fbbf24'; icon = '⚠️'; }
else if (level === 'error') { color = '#f87171'; icon = '❌'; }
else if (level === 'progress') { color = '#38bdf8'; icon = '⏳'; }
else if (level === 'stage') { color = '#c084fc'; icon = '⚙️'; }
const line = document.createElement('div');
line.style.cssText = `color: ${color}; margin-bottom: 2px; font-family: var(--font-mono); font-size: 0.73rem;`;
line.innerHTML = `<span style="color: #64748b;">[${timeStr}]</span> ${icon} ${escapeHtml(message)}`;
body.appendChild(line);
if (statusBadge) {
if (level === 'error') {
statusBadge.textContent = 'ERROR';
statusBadge.style.color = 'var(--accent-rose)';
statusBadge.style.background = 'rgba(244,63,94,0.15)';
} else if (level === 'progress' || level === 'stage') {
statusBadge.textContent = 'RUNNING';
statusBadge.style.color = 'var(--accent-cyan)';
statusBadge.style.background = 'rgba(6,182,212,0.15)';
} else if (level === 'success') {
statusBadge.textContent = 'SUCCESS';
statusBadge.style.color = 'var(--accent-green)';
statusBadge.style.background = 'rgba(16,185,129,0.15)';
}
}
if (autoscroll) {
body.scrollTop = body.scrollHeight;
}
}
const btnClearTranscodeTerm = document.getElementById('btn-clear-transcode-term');
if (btnClearTranscodeTerm) {
btnClearTranscodeTerm.addEventListener('click', () => {
const body = document.getElementById('transcode-terminal-body');
const statusBadge = document.getElementById('transcode-term-status');
if (body) body.innerHTML = '<span style="color: #64748b;">[System] Console cleared. Waiting for operations...</span>';
if (statusBadge) {
statusBadge.textContent = 'IDLE';
statusBadge.style.color = 'var(--text-muted)';
statusBadge.style.background = 'rgba(255,255,255,0.06)';
}
});
}
const btnCopyTranscodeTerm = document.getElementById('btn-copy-transcode-term');
if (btnCopyTranscodeTerm) {
btnCopyTranscodeTerm.addEventListener('click', () => {
const body = document.getElementById('transcode-terminal-body');
if (body) {
navigator.clipboard.writeText(body.innerText || '');
showToast('Terminal output copied to clipboard!', 'success');
}
});
}
async function executeSingleTranscodeReplace(item, btnEl) { async function executeSingleTranscodeReplace(item, btnEl) {
const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked; const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked;
const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked; const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked;
logTranscodeTerm(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, 'info');
logTranscodeTerm(`Initiating single replacement for: "${item.basename}"`, 'progress');
logTranscodeTerm(`Original Path: ${item.orig_local_path} (${(item.orig_size/(1024*1024)).toFixed(1)} MB)`, 'info');
logTranscodeTerm(`Transcode Path: ${item.transcode_path} (${(item.transcode_size/(1024*1024)).toFixed(1)} MB)`, 'info');
logTranscodeTerm(`Expected Space Saving: ${(item.saved_bytes/(1024*1024)).toFixed(1)} MB`, 'info');
try { try {
btnEl.disabled = true; btnEl.disabled = true;
btnEl.textContent = 'Replacing...'; btnEl.textContent = 'Replacing...';
logTranscodeTerm(`Stage 1/4: Staging transcode to "${item.orig_local_path}.transcode.tmp"...`, 'stage');
const res = await fetch('/api/stash/transcodes/replace', { const res = await fetch('/api/stash/transcodes/replace', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -8008,7 +8120,14 @@ Return ONLY a valid JSON object with an array named "results":
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
showToast(`✓ Replaced "${item.basename}" (Saved ${(data.saved_bytes/(1024*1024)).toFixed(1)} MB)!`, 'success'); logTranscodeTerm(`Stage 2/4: Atomic replace verified. Original video overwritten successfully.`, 'stage');
if (deleteCache) logTranscodeTerm(`Stage 3/4: Deleted transcode cache at ${item.transcode_path}`, 'stage');
if (triggerRescan) logTranscodeTerm(`Stage 4/4: Triggered background Stash rescan for parent directory.`, 'stage');
const savedMb = (data.saved_bytes/(1024*1024)).toFixed(1);
logTranscodeTerm(`✓ COMPLETED: "${item.basename}" replaced (Saved ${savedMb} MB)!`, 'success');
showToast(`✓ Replaced "${item.basename}" (Saved ${savedMb} MB)!`, 'success');
const row = document.getElementById(`transcode-row-${item.oshash}`); const row = document.getElementById(`transcode-row-${item.oshash}`);
if (row) { if (row) {
row.style.opacity = '0.4'; row.style.opacity = '0.4';
@@ -8016,18 +8135,20 @@ Return ONLY a valid JSON object with an array named "results":
btnEl.textContent = '✅ Replaced'; btnEl.textContent = '✅ Replaced';
} }
} else { } else {
logTranscodeTerm(`FAILED: ${data.error || 'Unknown error'}`, 'error');
showToast(`Replacement failed: ${data.error}`, 'error'); showToast(`Replacement failed: ${data.error}`, 'error');
btnEl.disabled = false; btnEl.disabled = false;
btnEl.textContent = '⚡ Replace'; btnEl.textContent = '⚡ Replace';
} }
} catch (err) { } catch (err) {
logTranscodeTerm(`EXCEPTION: ${err.message}`, 'error');
showToast(`Replace error: ${err.message}`, 'error'); showToast(`Replace error: ${err.message}`, 'error');
btnEl.disabled = false; btnEl.disabled = false;
btnEl.textContent = '⚡ Replace'; btnEl.textContent = '⚡ Replace';
} }
} }
// Batch Replace Action with Live Per-Item Progress // Batch Replace Action with Live Per-Item Progress & Terminal Logging
const btnBatchReplaceTranscodes = document.getElementById('btn-batch-replace-transcodes'); const btnBatchReplaceTranscodes = document.getElementById('btn-batch-replace-transcodes');
if (btnBatchReplaceTranscodes) { if (btnBatchReplaceTranscodes) {
btnBatchReplaceTranscodes.addEventListener('click', async () => { btnBatchReplaceTranscodes.addEventListener('click', async () => {
@@ -8046,13 +8167,19 @@ Return ONLY a valid JSON object with an array named "results":
const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked; const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked;
const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked; const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked;
logTranscodeTerm(`═══════════════════════════════════════════════════`, 'info');
logTranscodeTerm(`🚀 Starting Batch Replacement: ${selectedItems.length} items (Total Est. Savings: ${totalSavedMb} MB)`, 'progress');
let successCount = 0; let successCount = 0;
let totalSavedBytes = 0; let totalSavedBytes = 0;
btnBatchReplaceTranscodes.disabled = true; btnBatchReplaceTranscodes.disabled = true;
for (let i = 0; i < selectedItems.length; i++) { for (let i = 0; i < selectedItems.length; i++) {
const item = selectedItems[i]; const item = selectedItems[i];
btnBatchReplaceTranscodes.textContent = `Replacing ${i + 1} / ${selectedItems.length}...`; const currentIdx = i + 1;
btnBatchReplaceTranscodes.textContent = `Replacing ${currentIdx} / ${selectedItems.length}...`;
logTranscodeTerm(`[${currentIdx}/${selectedItems.length}] Processing "${item.basename}"...`, 'progress');
const row = document.getElementById(`transcode-row-${item.oshash}`); const row = document.getElementById(`transcode-row-${item.oshash}`);
const replaceBtn = row?.querySelector('.btn-transcode-replace-single'); const replaceBtn = row?.querySelector('.btn-transcode-replace-single');
@@ -8062,6 +8189,7 @@ Return ONLY a valid JSON object with an array named "results":
} }
try { try {
const isLast = (i === selectedItems.length - 1);
const res = await fetch('/api/stash/transcodes/replace', { const res = await fetch('/api/stash/transcodes/replace', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -8069,19 +8197,22 @@ Return ONLY a valid JSON object with an array named "results":
orig_local_path: item.orig_local_path, orig_local_path: item.orig_local_path,
transcode_path: item.transcode_path, transcode_path: item.transcode_path,
delete_transcode: deleteCache, delete_transcode: deleteCache,
trigger_rescan: (i === selectedItems.length - 1) ? triggerRescan : false trigger_rescan: isLast ? triggerRescan : false
}) })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
successCount++; successCount++;
const savedMb = ((data.saved_bytes || 0) / (1024*1024)).toFixed(1);
totalSavedBytes += (data.saved_bytes || 0); totalSavedBytes += (data.saved_bytes || 0);
logTranscodeTerm(`[${currentIdx}/${selectedItems.length}] ✓ Replaced "${item.basename}" (+${savedMb} MB saved)`, 'success');
if (row) { if (row) {
row.style.opacity = '0.4'; row.style.opacity = '0.4';
row.style.background = 'rgba(16, 185, 129, 0.1)'; row.style.background = 'rgba(16, 185, 129, 0.1)';
if (replaceBtn) replaceBtn.textContent = '✅ Replaced'; if (replaceBtn) replaceBtn.textContent = '✅ Replaced';
} }
} else { } else {
logTranscodeTerm(`[${currentIdx}/${selectedItems.length}] ❌ Failed: "${item.basename}" -> ${data.error}`, 'error');
if (replaceBtn) { if (replaceBtn) {
replaceBtn.disabled = false; replaceBtn.disabled = false;
replaceBtn.textContent = '❌ Failed'; replaceBtn.textContent = '❌ Failed';
@@ -8089,6 +8220,7 @@ Return ONLY a valid JSON object with an array named "results":
} }
} }
} catch (err) { } catch (err) {
logTranscodeTerm(`[${currentIdx}/${selectedItems.length}] ❌ Error: "${item.basename}" -> ${err.message}`, 'error');
if (replaceBtn) { if (replaceBtn) {
replaceBtn.disabled = false; replaceBtn.disabled = false;
replaceBtn.textContent = '❌ Error'; replaceBtn.textContent = '❌ Error';
@@ -8098,6 +8230,8 @@ Return ONLY a valid JSON object with an array named "results":
} }
const savedMb = (totalSavedBytes / (1024 * 1024)).toFixed(1); const savedMb = (totalSavedBytes / (1024 * 1024)).toFixed(1);
logTranscodeTerm(`═══════════════════════════════════════════════════`, 'info');
logTranscodeTerm(`🎉 Batch Complete! Successfully replaced ${successCount}/${selectedItems.length} files. Total space saved: ${savedMb} MB.`, 'success');
showToast(`✓ Batch completed: Replaced ${successCount} / ${selectedItems.length} files (Saved ${savedMb} MB)!`, 'success'); showToast(`✓ Batch completed: Replaced ${successCount} / ${selectedItems.length} files (Saved ${savedMb} MB)!`, 'success');
btnBatchReplaceTranscodes.disabled = false; btnBatchReplaceTranscodes.disabled = false;
btnBatchReplaceTranscodes.textContent = `⚡ Batch Replace Selected (${selectedTranscodeHashes.size})`; btnBatchReplaceTranscodes.textContent = `⚡ Batch Replace Selected (${selectedTranscodeHashes.size})`;
@@ -8113,6 +8247,7 @@ Return ONLY a valid JSON object with an array named "results":
const btnScanStashTranscodes = document.getElementById('btn-scan-stash-transcodes'); const btnScanStashTranscodes = document.getElementById('btn-scan-stash-transcodes');
if (btnScanStashTranscodes) btnScanStashTranscodes.addEventListener('click', scanStashTranscodes); if (btnScanStashTranscodes) btnScanStashTranscodes.addEventListener('click', scanStashTranscodes);
if (btnScanStashTranscodes) btnScanStashTranscodes.addEventListener('click', scanStashTranscodes);
// ==================== POST-MOVE PIPELINE AUTOMATION ==================== // ==================== POST-MOVE PIPELINE AUTOMATION ====================
async function triggerPostMoveAutomation(targetDir) { async function triggerPostMoveAutomation(targetDir) {