feat: Add real-time live terminal execution console to Stash Transcode module
This commit is contained in:
@@ -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');
|
||||||
if (statSavings) {
|
|
||||||
const gb = (data.total_potential_savings_bytes || 0) / (1024**3);
|
const gb = (data.total_potential_savings_bytes || 0) / (1024**3);
|
||||||
|
if (statSavings) {
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user