1829 lines
80 KiB
Plaintext
1829 lines
80 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>encoderPro Dashboard</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
padding: 30px;
|
||
border-radius: 12px;
|
||
margin-bottom: 30px;
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 2.5em;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.header p {
|
||
opacity: 0.9;
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
.controls {
|
||
background: #1e293b;
|
||
padding: 20px;
|
||
border-radius: 12px;
|
||
margin-bottom: 30px;
|
||
display: flex;
|
||
gap: 15px;
|
||
align-items: center;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.btn {
|
||
padding: 12px 24px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 1em;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: #10b981;
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #059669;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: #ef4444;
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #dc2626;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.status-badge {
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
font-size: 0.9em;
|
||
font-weight: 600;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.status-active {
|
||
background: #10b981;
|
||
color: white;
|
||
}
|
||
|
||
.status-idle {
|
||
background: #64748b;
|
||
color: white;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.card {
|
||
background: #1e293b;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.card:hover {
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 0.9em;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.card-value {
|
||
font-size: 2.5em;
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.card-subtitle {
|
||
color: #64748b;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.card-pending { border-left: 4px solid #fbbf24; }
|
||
.card-processing { border-left: 4px solid #3b82f6; }
|
||
.card-completed { border-left: 4px solid #10b981; }
|
||
.card-failed { border-left: 4px solid #ef4444; }
|
||
.card-skipped { border-left: 4px solid #64748b; }
|
||
|
||
.progress-section {
|
||
background: #1e293b;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
margin-bottom: 30px;
|
||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 30px;
|
||
background: #0f172a;
|
||
border-radius: 15px;
|
||
overflow: hidden;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #10b981, #059669);
|
||
transition: width 0.5s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.system-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.gpu-card {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
color: white;
|
||
}
|
||
|
||
.meter {
|
||
width: 100%;
|
||
height: 12px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.meter-fill {
|
||
height: 100%;
|
||
background: white;
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
.activity-list {
|
||
background: #1e293b;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.activity-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
background: #0f172a;
|
||
border-radius: 8px;
|
||
border-left: 3px solid #10b981;
|
||
}
|
||
|
||
.activity-item.failed {
|
||
border-left-color: #ef4444;
|
||
}
|
||
|
||
.activity-time {
|
||
color: #64748b;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.logs-container {
|
||
background: #0f172a;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.85em;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.log-line {
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid #1e293b;
|
||
}
|
||
|
||
.log-error {
|
||
color: #ef4444;
|
||
}
|
||
|
||
.log-success {
|
||
color: #10b981;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 1.5em;
|
||
margin-bottom: 20px;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #64748b;
|
||
}
|
||
|
||
/* File table enhancements */
|
||
#qualityTable tbody tr {
|
||
cursor: pointer;
|
||
}
|
||
|
||
#qualityTable tbody tr:hover {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
}
|
||
|
||
#qualityTable tbody tr.row-selected {
|
||
background: rgba(59, 130, 246, 0.15) !important;
|
||
border-left: 3px solid #3b82f6;
|
||
}
|
||
|
||
#qualityTable tbody tr.row-processing {
|
||
opacity: 0.7;
|
||
background: rgba(59, 130, 246, 0.05);
|
||
}
|
||
|
||
.file-checkbox {
|
||
accent-color: #3b82f6;
|
||
}
|
||
|
||
.spinner {
|
||
border: 3px solid #1e293b;
|
||
border-top: 3px solid #667eea;
|
||
border-radius: 50%;
|
||
width: 40px;
|
||
height: 40px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 20px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
|
||
.encoder-badge {
|
||
display: inline-block;
|
||
padding: 4px 12px;
|
||
background: #3b82f6;
|
||
border-radius: 12px;
|
||
font-size: 0.8em;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header h1 {
|
||
font-size: 1.8em;
|
||
}
|
||
|
||
.controls {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.status-badge {
|
||
margin-left: 0;
|
||
text-align: center;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<script>
|
||
// Store CSRF token
|
||
const csrfToken = '{{ csrf_token }}';
|
||
|
||
// Helper function to add CSRF token to fetch requests
|
||
function fetchWithCsrf(url, options = {}) {
|
||
// Properly merge headers to avoid mutation issues
|
||
options.headers = {
|
||
...(options.headers || {}),
|
||
'X-CSRF-Token': csrfToken
|
||
};
|
||
return fetch(url, options);
|
||
}
|
||
</script>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>🎬 encoderPro Dashboard</h1>
|
||
<p>GPU-Accelerated Processing System</p>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<button class="btn btn-danger" id="btnStop" onclick="stopProcessing()" disabled>
|
||
⏹ Stop Processing
|
||
</button>
|
||
<button class="btn" onclick="scanLibrary()" style="background: #f59e0b; color: white;" title="Scan movie library and detect subtitles">
|
||
📂 Scan Library
|
||
</button>
|
||
<button class="btn" onclick="refreshData()" style="background: #3b82f6; color: white;" title="Refresh dashboard data">
|
||
🔄 Refresh
|
||
</button>
|
||
<button class="btn" onclick="resetStuckFiles()" style="background: #8b5cf6; color: white;" title="Mark stuck files as failed for retry">
|
||
🔧 Reset Stuck
|
||
</button>
|
||
<span class="status-badge status-idle" id="statusBadge">Idle</span>
|
||
</div>
|
||
|
||
<!-- Processing Info Banner -->
|
||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 16px 20px; border-radius: 12px; margin-bottom: 20px; border-left: 4px solid #5a67d8;">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<span style="font-size: 24px;">ℹ️</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1em; margin-bottom: 4px; color: white;">How to Encode Movies</div>
|
||
<div style="opacity: 0.95; font-size: 0.9em; color: white;">
|
||
<strong>1.</strong> Select movies using checkboxes or Quick Select buttons below.
|
||
<strong>2.</strong> Choose an encoding profile.
|
||
<strong>3.</strong> Click "Encode Selected" to start encoding immediately.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="emptyNotice" style="display: none; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 20px; border-radius: 12px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<h3 style="margin: 0 0 10px 0; font-size: 1.3em;">📂 Database is Empty</h3>
|
||
<p style="margin: 0 0 15px 0;">No files found in the database. Click "Scan Library" to analyze your movie collection and populate the database.</p>
|
||
<button class="btn btn-primary" onclick="scanLibrary()">
|
||
📂 Scan Library Now
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Hardware Encoders Section -->
|
||
<div class="section" style="margin-bottom: 20px;">
|
||
<h2 class="section-title">Hardware Encoders</h2>
|
||
<div id="encodersInfo" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 15px;">
|
||
<div class="loading">Detecting hardware encoders...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="card card-pending">
|
||
<div class="card-title">Discovered</div>
|
||
<div class="card-value" id="statDiscovered">-</div>
|
||
<div class="card-subtitle">Files found (not selected)</div>
|
||
</div>
|
||
<div class="card card-processing">
|
||
<div class="card-title">Processing</div>
|
||
<div class="card-value" id="statProcessing">-</div>
|
||
<div class="card-subtitle">Currently encoding</div>
|
||
</div>
|
||
<div class="card card-completed">
|
||
<div class="card-title">Completed</div>
|
||
<div class="card-value" id="statCompleted">-</div>
|
||
<div class="card-subtitle">Successfully processed</div>
|
||
</div>
|
||
<div class="card card-failed">
|
||
<div class="card-title">Failed</div>
|
||
<div class="card-value" id="statFailed">-</div>
|
||
<div class="card-subtitle">Encoding errors</div>
|
||
</div>
|
||
<div class="card card-skipped">
|
||
<div class="card-title">Skipped</div>
|
||
<div class="card-value" id="statSkipped">-</div>
|
||
<div class="card-subtitle">No subtitles found</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="progress-section">
|
||
<h2 class="section-title">Progress Overview</h2>
|
||
<div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||
<span>Overall Progress</span>
|
||
<span id="progressText">0%</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="progressBar" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 20px;">
|
||
<div>
|
||
<strong>Original Size:</strong><br>
|
||
<span id="originalSize" style="font-size: 1.5em;">-</span>
|
||
</div>
|
||
<div>
|
||
<strong>Encoded Size:</strong><br>
|
||
<span id="encodedSize" style="font-size: 1.5em;">-</span>
|
||
</div>
|
||
<div>
|
||
<strong>Space Saved:</strong><br>
|
||
<span id="spaceSaved" style="font-size: 1.5em; color: #10b981;">-</span>
|
||
</div>
|
||
<div>
|
||
<strong>Average FPS:</strong><br>
|
||
<span id="avgFps" style="font-size: 1.5em; color: #3b82f6;">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="system-stats" id="systemStats"></div>
|
||
|
||
<div style="background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 30px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 class="section-title" style="margin: 0;">Encoding Settings</h2>
|
||
<button class="btn" onclick="toggleQualitySettings()" style="background: #8b5cf6; color: white;">
|
||
⚙️ Configure
|
||
</button>
|
||
</div>
|
||
<div id="qualitySettings" style="display: none;">
|
||
<!-- Encoding Profile Selection -->
|
||
<div style="margin-bottom: 25px; padding: 20px; background: #0f172a; border-radius: 8px; border-left: 3px solid #10b981;">
|
||
<h3 style="margin: 0 0 15px 0; color: #10b981; font-size: 1.2em;">📹 Encoding Profile</h3>
|
||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 20px;">
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: #94a3b8;">Select Profile</label>
|
||
<select id="encodingProfile" onchange="updateProfileDescription()" style="width: 100%; padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
<option value="">Loading profiles...</option>
|
||
</select>
|
||
</div>
|
||
<div id="profileDescription" style="padding: 15px; background: #1e293b; border-radius: 6px; color: #94a3b8; font-size: 0.9em;">
|
||
Select a profile to see details
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quality Check Settings -->
|
||
<div style="margin-bottom: 25px; padding: 20px; background: #0f172a; border-radius: 8px; border-left: 3px solid #f59e0b;">
|
||
<h3 style="margin: 0 0 15px 0; color: #f59e0b; font-size: 1.2em;">🎯 Quality Check Settings</h3>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px;">
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: #94a3b8;">Enable Quality Check</label>
|
||
<select id="qualityEnabled" style="width: 100%; padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
<option value="true">Enabled</option>
|
||
<option value="false">Disabled</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: #94a3b8;">Warning Threshold (points)</label>
|
||
<input type="number" id="warnThreshold" min="0" max="50" step="0.5" value="10.0"
|
||
style="width: 100%; padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
</div>
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: #94a3b8;">Error Threshold (points)</label>
|
||
<input type="number" id="errorThreshold" min="0" max="50" step="0.5" value="20.0"
|
||
style="width: 100%; padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
</div>
|
||
<div>
|
||
<label style="display: block; margin-bottom: 8px; color: #94a3b8;">Skip on Degradation</label>
|
||
<select id="skipOnDegradation" style="width: 100%; padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
<option value="false">No - Warn Only</option>
|
||
<option value="true">Yes - Auto Skip</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 15px; padding: 15px; background: #1e293b; border-radius: 6px;">
|
||
<strong style="color: #3b82f6;">ℹ️ Quality Check Info:</strong>
|
||
<ul style="margin: 10px 0 0 20px; color: #94a3b8; font-size: 0.9em;">
|
||
<li>Quality scores range from 0-100 (higher is better)</li>
|
||
<li>Warning triggers when quality drops by threshold points</li>
|
||
<li>Error threshold prevents encoding poor quality sources</li>
|
||
<li>Auto-skip will skip files that would degrade quality</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 20px; display: flex; gap: 10px;">
|
||
<button class="btn btn-primary" onclick="saveEncodingSettings()">💾 Save All Settings</button>
|
||
<button class="btn" onclick="loadQualitySettings()" style="background: #64748b; color: white;">🔄 Reload</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 30px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 class="section-title" style="margin: 0;">Movie Selection & Encoding</h2>
|
||
<div style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); padding: 8px 16px; border-radius: 8px; font-weight: 600;">
|
||
<span id="selectedCount" style="color: white;">0 files selected</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Selection Info Banner -->
|
||
<div id="selectionBanner" style="display: none; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 16px 20px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #b45309;">
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<span style="font-size: 24px;">⚡</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em; margin-bottom: 4px;">Selection Mode Active</div>
|
||
<div style="opacity: 0.95; font-size: 0.9em;">Only selected movies will be encoded when you start processing. Use checkboxes to select files.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filters and Actions -->
|
||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 15px; padding: 16px; background: #0f172a; border-radius: 8px;">
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<label style="color: #94a3b8; font-weight: 500;">Filter:</label>
|
||
<select id="qualityFilter" onchange="loadFileQuality()" style="padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155;">
|
||
<option value="all">All Files</option>
|
||
<option value="discovered">Discovered</option>
|
||
<option value="pending">Selected (Pending)</option>
|
||
<option value="processing">Processing</option>
|
||
<option value="completed">Completed</option>
|
||
<option value="failed">Failed</option>
|
||
<option value="skipped">Skipped</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<label style="color: #94a3b8; font-weight: 500;">Search:</label>
|
||
<input type="text" id="fileSearch" placeholder="Filter by filename..."
|
||
onkeyup="filterFileTable()"
|
||
style="padding: 10px; border-radius: 6px; background: #1e293b; color: #e2e8f0; border: 1px solid #334155; width: 250px;">
|
||
</div>
|
||
|
||
<button class="btn" onclick="loadFileQuality()" style="background: #3b82f6; color: white;">
|
||
🔄 Refresh
|
||
</button>
|
||
|
||
<div style="flex: 1;"></div>
|
||
|
||
<button class="btn" onclick="clearSelection()" style="background: #64748b; color: white;">
|
||
✕ Clear Selection
|
||
</button>
|
||
|
||
<div style="border-left: 2px solid #334155; height: 30px; margin: 0 5px;"></div>
|
||
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="btn" onclick="quickSelect('discovered')" style="background: #8b5cf6; color: white; font-size: 0.9em;" title="Select all visible discovered files (load more to select additional files)">
|
||
📁 Select Visible Discovered
|
||
</button>
|
||
<button class="btn" onclick="quickSelect('failed')" style="background: #ef4444; color: white; font-size: 0.9em;" title="Select all visible failed files to retry (load more to select additional files)">
|
||
🔄 Select Visible Failed
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Buttons -->
|
||
<div style="background: #0f172a; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
|
||
<div style="margin-bottom: 10px; color: #94a3b8; font-weight: 600; font-size: 0.9em;">Filter by Attributes:</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
<button class="btn filter-btn" onclick="applyFilter('all')" data-filter="all" style="background: #3b82f6; color: white; font-size: 0.85em;">
|
||
All Videos
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('has_subtitles')" data-filter="has_subtitles" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
📝 Has Subtitles
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('no_subtitles')" data-filter="no_subtitles" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
❌ No Subtitles
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('surround_sound')" data-filter="surround_sound" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
🔊 5.1+ Audio
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('stereo_only')" data-filter="stereo_only" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
🔉 Stereo Only
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('large_files')" data-filter="large_files" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
💾 Large Files (>5GB)
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('4k')" data-filter="4k" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
📺 4K
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('1080p')" data-filter="1080p" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
📺 1080p
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('h264')" data-filter="h264" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
🎞️ H.264
|
||
</button>
|
||
<button class="btn filter-btn" onclick="applyFilter('h265')" data-filter="h265" style="background: #64748b; color: white; font-size: 0.85em;">
|
||
🎞️ H.265
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Encoding Action Bar -->
|
||
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 20px; border-radius: 8px; margin-bottom: 15px;">
|
||
<div style="display: flex; gap: 15px; align-items: center; flex-wrap: wrap;">
|
||
<div style="flex: 1; min-width: 200px;">
|
||
<label style="display: block; color: white; font-weight: 600; margin-bottom: 8px; font-size: 0.95em;">Encoding Profile</label>
|
||
<select id="reencodeProfile" onchange="updateSelectedCount()" style="width: 100%; padding: 12px; border-radius: 6px; background: white; color: #1e293b; border: none; font-weight: 500;">
|
||
<option value="">-- Select Profile --</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; align-items: flex-end;">
|
||
<button class="btn" onclick="encodeSelected()" style="background: white; color: #059669; font-weight: 600; padding: 12px 24px; font-size: 1.05em; border: 2px solid white;" id="btnEncode" disabled>
|
||
▶️ Encode Selected
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="overflow-x: auto;">
|
||
<table id="qualityTable" style="width: 100%; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="background: #0f172a; text-align: left;">
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155; width: 40px;">
|
||
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()" style="cursor: pointer;">
|
||
</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">File</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">State</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">Resolution</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">Original Size</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">Encoded Size</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">Savings</th>
|
||
<th style="padding: 12px; border-bottom: 2px solid #334155;">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="qualityTableBody">
|
||
<tr>
|
||
<td colspan="8" style="text-align: center; padding: 40px; color: #64748b;">
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
Loading file data...
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="activity-list">
|
||
<h2 class="section-title">Recent Activity</h2>
|
||
<div id="activityList">
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
Loading activity...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background: #1e293b; border-radius: 12px; padding: 24px; margin-top: 30px;">
|
||
<h2 class="section-title">Live Logs</h2>
|
||
<div class="logs-container" id="logsContainer">
|
||
<div class="loading">Loading logs...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let refreshInterval;
|
||
let isProcessing = false;
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
refreshData();
|
||
startAutoRefresh();
|
||
loadQualitySettings();
|
||
loadEncodingProfiles();
|
||
loadFileQuality();
|
||
|
||
// Add infinite scroll
|
||
const tableContainer = document.querySelector('.section:has(#qualityTable)');
|
||
if (tableContainer) {
|
||
tableContainer.addEventListener('scroll', handleScroll);
|
||
}
|
||
window.addEventListener('scroll', handleScroll);
|
||
|
||
// Add change listener for reencode profile dropdown
|
||
document.getElementById('reencodeProfile').addEventListener('change', updateSelectedCount);
|
||
});
|
||
|
||
function startAutoRefresh() {
|
||
refreshInterval = setInterval(refreshData, 5000); // Refresh every 5 seconds
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval);
|
||
}
|
||
}
|
||
|
||
async function refreshData() {
|
||
await Promise.all([
|
||
updateStats(),
|
||
updateSystemStats(),
|
||
updateActivity(),
|
||
updateLogs(),
|
||
checkProcessingStatus(),
|
||
updateEncoders()
|
||
]);
|
||
}
|
||
|
||
async function updateStats() {
|
||
try {
|
||
const response = await fetch('/api/stats');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const data = result.data;
|
||
|
||
document.getElementById('statDiscovered').textContent = data.discovered || 0;
|
||
document.getElementById('statProcessing').textContent = data.processing || 0;
|
||
document.getElementById('statCompleted').textContent = data.completed || 0;
|
||
document.getElementById('statFailed').textContent = data.failed || 0;
|
||
document.getElementById('statSkipped').textContent = data.skipped || 0;
|
||
|
||
// Progress
|
||
const total = (data.completed || 0) + (data.failed || 0) + (data.pending || 0) + (data.processing || 0) + (data.skipped || 0);
|
||
const completed = (data.completed || 0) + (data.failed || 0);
|
||
const progress = total > 0 ? (completed / total * 100) : 0;
|
||
|
||
// Check if database is empty and show notice
|
||
if (total === 0) {
|
||
showEmptyDatabaseNotice();
|
||
} else {
|
||
hideEmptyDatabaseNotice();
|
||
}
|
||
|
||
document.getElementById('progressBar').style.width = progress + '%';
|
||
document.getElementById('progressText').textContent = progress.toFixed(1) + '%';
|
||
|
||
// Sizes
|
||
document.getElementById('originalSize').textContent = formatBytes(data.original_size);
|
||
document.getElementById('encodedSize').textContent = formatBytes(data.encoded_size);
|
||
document.getElementById('spaceSaved').textContent =
|
||
formatBytes(data.space_saved) + ' (' + data.space_saved_percent + '%)';
|
||
document.getElementById('avgFps').textContent =
|
||
(data.avg_fps || 0).toFixed(1) + ' fps';
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update stats:', error);
|
||
}
|
||
}
|
||
|
||
async function updateSystemStats() {
|
||
try {
|
||
const response = await fetch('/api/system');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const data = result.data;
|
||
let html = '';
|
||
|
||
// GPU stats
|
||
if (data.gpu && data.gpu.length > 0) {
|
||
data.gpu.forEach(gpu => {
|
||
html += `
|
||
<div class="gpu-card">
|
||
<h3>🎮 ${gpu.name}</h3>
|
||
<div style="margin-top: 15px;">
|
||
<div style="display: flex; justify-content: space-between;">
|
||
<span>GPU Usage</span>
|
||
<span>${gpu.utilization}%</span>
|
||
</div>
|
||
<div class="meter">
|
||
<div class="meter-fill" style="width: ${gpu.utilization}%"></div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-top: 10px;">
|
||
<span>Memory</span>
|
||
<span>${gpu.memory_used} / ${gpu.memory_total} MB</span>
|
||
</div>
|
||
<div class="meter">
|
||
<div class="meter-fill" style="width: ${(gpu.memory_used/gpu.memory_total)*100}%"></div>
|
||
</div>
|
||
<div style="margin-top: 10px;">
|
||
Temperature: ${gpu.temperature}°C
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// CPU stats
|
||
if (data.cpu) {
|
||
html += `
|
||
<div class="card">
|
||
<h3>💻 CPU</h3>
|
||
<div style="margin-top: 15px;">
|
||
<div style="display: flex; justify-content: space-between;">
|
||
<span>Load Average</span>
|
||
<span>${data.cpu.load_1m.toFixed(2)}</span>
|
||
</div>
|
||
<div class="meter">
|
||
<div class="meter-fill" style="width: ${data.cpu.load_percent}%; background: #3b82f6;"></div>
|
||
</div>
|
||
<div style="margin-top: 10px; color: #94a3b8;">
|
||
${data.cpu.cpu_count} cores
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('systemStats').innerHTML = html;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update system stats:', error);
|
||
}
|
||
}
|
||
|
||
async function updateEncoders() {
|
||
try {
|
||
const response = await fetch('/api/encoders');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const encoders = result.encoders;
|
||
let html = '';
|
||
|
||
// Intel QSV
|
||
if (encoders.intel.available) {
|
||
const codecs = [];
|
||
if (encoders.intel.h264) codecs.push('H.264');
|
||
if (encoders.intel.h265) codecs.push('H.265');
|
||
if (encoders.intel.av1) codecs.push('AV1');
|
||
|
||
html += `
|
||
<div style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); padding: 16px; border-radius: 12px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||
<span style="font-size: 24px;">🔷</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em;">Intel QSV</div>
|
||
<div style="opacity: 0.9; font-size: 0.85em;">Hardware Accelerated</div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.15); padding: 8px 12px; border-radius: 6px; font-size: 0.9em;">
|
||
<strong>Codecs:</strong> ${codecs.join(', ')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// NVIDIA NVENC
|
||
if (encoders.nvidia.available) {
|
||
const codecs = [];
|
||
if (encoders.nvidia.h264) codecs.push('H.264');
|
||
if (encoders.nvidia.h265) codecs.push('H.265');
|
||
if (encoders.nvidia.av1) codecs.push('AV1');
|
||
|
||
html += `
|
||
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 16px; border-radius: 12px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||
<span style="font-size: 24px;">🟢</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em;">NVIDIA NVENC</div>
|
||
<div style="opacity: 0.9; font-size: 0.85em;">Hardware Accelerated</div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.15); padding: 8px 12px; border-radius: 6px; font-size: 0.9em;">
|
||
<strong>Codecs:</strong> ${codecs.join(', ')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// AMD VAAPI
|
||
if (encoders.amd.available) {
|
||
const codecs = [];
|
||
if (encoders.amd.h264) codecs.push('H.264');
|
||
if (encoders.amd.h265) codecs.push('H.265');
|
||
if (encoders.amd.av1) codecs.push('AV1');
|
||
|
||
html += `
|
||
<div style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); padding: 16px; border-radius: 12px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||
<span style="font-size: 24px;">🔴</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em;">AMD VAAPI</div>
|
||
<div style="opacity: 0.9; font-size: 0.85em;">Hardware Accelerated</div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.15); padding: 8px 12px; border-radius: 6px; font-size: 0.9em;">
|
||
<strong>Codecs:</strong> ${codecs.join(', ')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// CPU fallback (always show)
|
||
const cpuCodecs = [];
|
||
if (encoders.cpu.h264) cpuCodecs.push('H.264');
|
||
if (encoders.cpu.h265) cpuCodecs.push('H.265');
|
||
if (encoders.cpu.av1) cpuCodecs.push('AV1');
|
||
|
||
html += `
|
||
<div style="background: linear-gradient(135deg, #64748b 0%, #475569 100%); padding: 16px; border-radius: 12px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||
<span style="font-size: 24px;">💻</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em;">CPU</div>
|
||
<div style="opacity: 0.9; font-size: 0.85em;">Software Encoding</div>
|
||
</div>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.15); padding: 8px 12px; border-radius: 6px; font-size: 0.9em;">
|
||
<strong>Codecs:</strong> ${cpuCodecs.join(', ')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Show message if no GPU encoders detected
|
||
if (!encoders.intel.available && !encoders.nvidia.available && !encoders.amd.available) {
|
||
html = `
|
||
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 16px; border-radius: 12px; color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1); grid-column: 1 / -1;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<span style="font-size: 24px;">⚠️</span>
|
||
<div>
|
||
<div style="font-weight: 600; font-size: 1.1em; margin-bottom: 4px;">No GPU Encoders Detected</div>
|
||
<div style="opacity: 0.9; font-size: 0.9em;">
|
||
Only CPU encoding available. Check GPU drivers and FFmpeg build for hardware acceleration support.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` + html;
|
||
}
|
||
|
||
document.getElementById('encodersInfo').innerHTML = html;
|
||
} else {
|
||
document.getElementById('encodersInfo').innerHTML = `
|
||
<div style="text-align: center; color: #ef4444; padding: 20px; grid-column: 1 / -1;">
|
||
Failed to detect encoders: ${result.error || 'Unknown error'}
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update encoders:', error);
|
||
document.getElementById('encodersInfo').innerHTML = `
|
||
<div style="text-align: center; color: #ef4444; padding: 20px; grid-column: 1 / -1;">
|
||
Error loading encoder information
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async function updateActivity() {
|
||
try {
|
||
const response = await fetch('/api/activity?limit=10');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const data = result.data;
|
||
let html = '';
|
||
|
||
if (data.length === 0) {
|
||
html = '<div style="text-align: center; color: #64748b; padding: 20px;">No recent activity</div>';
|
||
} else {
|
||
data.forEach(item => {
|
||
const failedClass = item.state === 'failed' ? 'failed' : '';
|
||
const icon = item.state === 'completed' ? '✓' : '✗';
|
||
const encoderEscaped = item.encoder_used ? `<span class="encoder-badge">${escapeHtml(item.encoder_used)}</span>` : '';
|
||
const fps = item.fps ? `${item.fps.toFixed(1)} fps` : '';
|
||
|
||
html += `
|
||
<div class="activity-item ${failedClass}">
|
||
<div>
|
||
<span style="margin-right: 10px;">${icon}</span>
|
||
<strong>${escapeHtml(item.relative_path)}</strong>
|
||
${encoderEscaped}
|
||
${fps ? `<span style="color: #64748b; margin-left: 10px;">${fps}</span>` : ''}
|
||
</div>
|
||
<div class="activity-time">${formatDate(item.updated_at)}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
document.getElementById('activityList').innerHTML = html;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update activity:', error);
|
||
}
|
||
}
|
||
|
||
async function updateLogs() {
|
||
try {
|
||
const response = await fetch('/api/logs?lines=50');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const lines = result.data;
|
||
let html = '';
|
||
|
||
lines.forEach(line => {
|
||
let className = 'log-line';
|
||
if (line.includes('ERROR')) className += ' log-error';
|
||
if (line.includes('SUCCESS') || line.includes('✓')) className += ' log-success';
|
||
|
||
html += `<div class="${className}">${escapeHtml(line)}</div>`;
|
||
});
|
||
|
||
const container = document.getElementById('logsContainer');
|
||
container.innerHTML = html;
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update logs:', error);
|
||
}
|
||
}
|
||
|
||
async function checkProcessingStatus() {
|
||
try {
|
||
const response = await fetch('/api/processing');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
isProcessing = result.data.active;
|
||
updateUIState();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to check processing status:', error);
|
||
}
|
||
}
|
||
|
||
function updateUIState() {
|
||
const btnStop = document.getElementById('btnStop');
|
||
const statusBadge = document.getElementById('statusBadge');
|
||
|
||
if (isProcessing) {
|
||
if (btnStop) btnStop.disabled = false;
|
||
if (statusBadge) {
|
||
statusBadge.textContent = 'Processing';
|
||
statusBadge.className = 'status-badge status-active';
|
||
}
|
||
} else {
|
||
if (btnStop) btnStop.disabled = true;
|
||
if (statusBadge) {
|
||
statusBadge.textContent = 'Idle';
|
||
statusBadge.className = 'status-badge status-idle';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startProcessing() {
|
||
try {
|
||
const response = await fetchWithCsrf('/api/jobs/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
isProcessing = true;
|
||
updateUIState();
|
||
setTimeout(refreshData, 1000);
|
||
} else {
|
||
alert('Failed to start processing: ' + (result.message || result.error || 'Unknown error'));
|
||
}
|
||
} catch (error) {
|
||
alert('Error starting processing: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function stopProcessing() {
|
||
if (!confirm('Are you sure you want to stop processing?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetchWithCsrf('/api/jobs/stop', {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
isProcessing = false;
|
||
updateUIState();
|
||
setTimeout(refreshData, 1000);
|
||
} else {
|
||
alert('Failed to stop processing: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('Error stopping processing: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function scanLibrary() {
|
||
if (!confirm('This will scan your movie library and populate the database. Continue?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetchWithCsrf('/api/jobs/scan', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert('Library scan started! This may take a few minutes.\n\nRefresh the page to see progress.');
|
||
setTimeout(refreshData, 2000);
|
||
} else {
|
||
alert('Failed to start scan: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('Error starting scan: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function resetStuckFiles() {
|
||
if (!confirm('This will mark all files stuck in "processing" state as FAILED.\n\nThey can then be retried. Continue?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetchWithCsrf('/api/jobs/reset-stuck', {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert('✅ Stuck files have been marked as failed and can be retried!');
|
||
setTimeout(refreshData, 1000);
|
||
} else {
|
||
alert('Failed to reset stuck files: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('Error resetting stuck files: ' + error.message);
|
||
}
|
||
}
|
||
|
||
function showEmptyDatabaseNotice() {
|
||
const notice = document.getElementById('emptyNotice');
|
||
if (notice) {
|
||
notice.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function hideEmptyDatabaseNotice() {
|
||
const notice = document.getElementById('emptyNotice');
|
||
if (notice) {
|
||
notice.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
const date = new Date(dateStr);
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
const seconds = Math.floor(diff / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
|
||
if (seconds < 60) return 'just now';
|
||
if (minutes < 60) return minutes + 'm ago';
|
||
if (hours < 24) return hours + 'h ago';
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function getHardwareBadge(encoder) {
|
||
if (!encoder) return '';
|
||
|
||
const encoderLower = encoder.toLowerCase();
|
||
|
||
// Intel QSV
|
||
if (encoderLower.includes('qsv') || encoderLower.includes('intel')) {
|
||
return '[Intel GPU]';
|
||
}
|
||
|
||
// NVIDIA NVENC
|
||
if (encoderLower.includes('nvenc') || encoderLower.includes('nvidia')) {
|
||
return '[NVIDIA GPU]';
|
||
}
|
||
|
||
// AMD VAAPI
|
||
if (encoderLower.includes('vaapi') || encoderLower.includes('amd')) {
|
||
return '[AMD GPU]';
|
||
}
|
||
|
||
// CPU encoders
|
||
if (encoderLower.includes('cpu') || encoderLower.includes('x264') ||
|
||
encoderLower.includes('x265') || encoderLower.includes('libx')) {
|
||
return '[CPU]';
|
||
}
|
||
|
||
return '[CPU]'; // Default to CPU if unknown
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
if (!text) return '';
|
||
return String(text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// Quality Settings Functions
|
||
function toggleQualitySettings() {
|
||
const settings = document.getElementById('qualitySettings');
|
||
settings.style.display = settings.style.display === 'none' ? 'block' : 'none';
|
||
}
|
||
|
||
async function loadQualitySettings() {
|
||
try {
|
||
const response = await fetch('/api/config');
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data.quality_check) {
|
||
const qc = result.data.quality_check;
|
||
document.getElementById('qualityEnabled').value = qc.enabled ? 'true' : 'false';
|
||
document.getElementById('warnThreshold').value = qc.warn_threshold || 10.0;
|
||
document.getElementById('errorThreshold').value = qc.error_threshold || 20.0;
|
||
document.getElementById('skipOnDegradation').value = qc.skip_on_degradation ? 'true' : 'false';
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load quality settings:', error);
|
||
}
|
||
}
|
||
|
||
// Profile management
|
||
let availableProfiles = {};
|
||
|
||
async function loadEncodingProfiles() {
|
||
try {
|
||
const response = await fetch('/api/profiles');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
availableProfiles = result.data.profiles;
|
||
const defaultProfile = result.data.default;
|
||
|
||
// Populate encoding profile dropdown (settings area)
|
||
const select = document.getElementById('encodingProfile');
|
||
let html = '<option value="">-- Select Profile --</option>';
|
||
|
||
for (const [name, profile] of Object.entries(availableProfiles)) {
|
||
const selected = name === defaultProfile ? 'selected' : '';
|
||
const hwBadge = getHardwareBadge(profile.encoder);
|
||
html += `<option value="${name}" ${selected}>${name} ${hwBadge}</option>`;
|
||
}
|
||
|
||
select.innerHTML = html;
|
||
|
||
// Populate reencode profile dropdown (file table area)
|
||
const reencodeSelect = document.getElementById('reencodeProfile');
|
||
let reencodeHtml = '<option value="">-- Select Profile --</option>';
|
||
|
||
for (const [name, profile] of Object.entries(availableProfiles)) {
|
||
const selected = name === defaultProfile ? 'selected' : '';
|
||
const hwBadge = getHardwareBadge(profile.encoder);
|
||
reencodeHtml += `<option value="${name}" ${selected}>${name} ${hwBadge}</option>`;
|
||
}
|
||
|
||
reencodeSelect.innerHTML = reencodeHtml;
|
||
|
||
// Update description for default profile
|
||
if (defaultProfile && availableProfiles[defaultProfile]) {
|
||
updateProfileDescription();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load profiles:', error);
|
||
document.getElementById('encodingProfile').innerHTML = '<option value="">Error loading profiles</option>';
|
||
document.getElementById('reencodeProfile').innerHTML = '<option value="">Error loading profiles</option>';
|
||
}
|
||
}
|
||
|
||
function updateProfileDescription() {
|
||
const select = document.getElementById('encodingProfile');
|
||
const profileName = select.value;
|
||
const descDiv = document.getElementById('profileDescription');
|
||
|
||
if (!profileName || !availableProfiles[profileName]) {
|
||
descDiv.innerHTML = 'Select a profile to see details';
|
||
return;
|
||
}
|
||
|
||
const profile = availableProfiles[profileName];
|
||
const hwBadge = getHardwareBadge(profile.encoder);
|
||
|
||
// Determine hardware type and color
|
||
let hwType = 'CPU';
|
||
let hwColor = '#64748b';
|
||
let hwIcon = '💻';
|
||
|
||
if (hwBadge.includes('Intel')) {
|
||
hwType = 'Intel QSV (GPU Accelerated)';
|
||
hwColor = '#0ea5e9';
|
||
hwIcon = '🔷';
|
||
} else if (hwBadge.includes('NVIDIA')) {
|
||
hwType = 'NVIDIA NVENC (GPU Accelerated)';
|
||
hwColor = '#10b981';
|
||
hwIcon = '🟢';
|
||
} else if (hwBadge.includes('AMD')) {
|
||
hwType = 'AMD VAAPI (GPU Accelerated)';
|
||
hwColor = '#ef4444';
|
||
hwIcon = '🔴';
|
||
}
|
||
|
||
let html = '<div style="line-height: 1.6;">';
|
||
|
||
// Hardware badge with color
|
||
html += `<div style="margin-bottom: 12px; padding: 10px; background: ${hwColor}; color: white; border-radius: 6px; font-weight: 600; display: flex; align-items: center; gap: 8px;">`;
|
||
html += `<span style="font-size: 20px;">${hwIcon}</span>`;
|
||
html += `<span>${hwType}</span>`;
|
||
html += `</div>`;
|
||
|
||
html += `<div style="margin-bottom: 8px;"><strong>Encoder:</strong> ${profile.encoder || 'N/A'}</div>`;
|
||
html += `<div style="margin-bottom: 8px;"><strong>Quality:</strong> CRF ${profile.quality || 'N/A'}</div>`;
|
||
html += `<div style="margin-bottom: 8px;"><strong>Preset:</strong> ${profile.preset || 'N/A'}</div>`;
|
||
|
||
if (profile.description) {
|
||
html += `<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #334155; color: #94a3b8; font-style: italic;">${profile.description}</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
descDiv.innerHTML = html;
|
||
}
|
||
|
||
async function saveEncodingSettings() {
|
||
try {
|
||
// Get current config
|
||
const configResponse = await fetch('/api/config');
|
||
const configResult = await configResponse.json();
|
||
|
||
if (!configResult.success) {
|
||
alert('Failed to load current configuration');
|
||
return;
|
||
}
|
||
|
||
const config = configResult.data;
|
||
|
||
// Update default profile
|
||
const selectedProfile = document.getElementById('encodingProfile').value;
|
||
if (selectedProfile) {
|
||
if (!config.profiles) {
|
||
config.profiles = {};
|
||
}
|
||
config.profiles.default = selectedProfile;
|
||
}
|
||
|
||
// Update quality_check section
|
||
config.quality_check = {
|
||
enabled: document.getElementById('qualityEnabled').value === 'true',
|
||
warn_threshold: parseFloat(document.getElementById('warnThreshold').value),
|
||
error_threshold: parseFloat(document.getElementById('errorThreshold').value),
|
||
skip_on_degradation: document.getElementById('skipOnDegradation').value === 'true',
|
||
prompt_on_warning: true
|
||
};
|
||
|
||
// Save config
|
||
const saveResponse = await fetchWithCsrf('/api/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
});
|
||
const saveResult = await saveResponse.json();
|
||
|
||
if (saveResult.success) {
|
||
alert('✅ Encoding settings saved successfully!\n\nRestart processing for changes to take effect.');
|
||
} else {
|
||
alert('❌ Failed to save settings: ' + saveResult.error);
|
||
}
|
||
} catch (error) {
|
||
alert('Error saving encoding settings: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Keep old function name for compatibility
|
||
async function saveQualitySettings() {
|
||
await saveEncodingSettings();
|
||
}
|
||
|
||
// File Quality Analysis
|
||
let selectedFiles = new Set();
|
||
let currentAttributeFilter = null; // Track the current attribute filter
|
||
let currentOffset = 0;
|
||
let hasMoreFiles = true;
|
||
let isLoadingMore = false;
|
||
|
||
async function loadFileQuality(append = false) {
|
||
const filter = document.getElementById('qualityFilter').value;
|
||
const tbody = document.getElementById('qualityTableBody');
|
||
|
||
// Clear selections and reset offset when changing filter (not appending)
|
||
if (!append) {
|
||
selectedFiles.clear();
|
||
updateSelectedCount();
|
||
currentOffset = 0;
|
||
hasMoreFiles = true;
|
||
}
|
||
|
||
if (!hasMoreFiles || isLoadingMore) {
|
||
return;
|
||
}
|
||
|
||
isLoadingMore = true;
|
||
|
||
try {
|
||
// Build URL with state filter and attribute filter
|
||
let url = `/api/files?limit=100&offset=${currentOffset}`;
|
||
if (filter !== 'all') {
|
||
url += `&state=${filter}`;
|
||
}
|
||
if (currentAttributeFilter && currentAttributeFilter !== 'all') {
|
||
url += `&filter=${currentAttributeFilter}`;
|
||
}
|
||
|
||
const response = await fetch(url);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const files = result.data;
|
||
let html = '';
|
||
|
||
if (files.length === 0) {
|
||
html = '<tr><td colspan="8" style="text-align: center; padding: 40px; color: #64748b;">No files found</td></tr>';
|
||
} else {
|
||
files.forEach(file => {
|
||
const savings = file.original_size && file.encoded_size ?
|
||
((file.original_size - file.encoded_size) / file.original_size * 100).toFixed(1) : '-';
|
||
|
||
const stateBadgeColors = {
|
||
'discovered': '#8b5cf6', // Purple - found but not selected
|
||
'pending': '#fbbf24', // Yellow - selected for encoding
|
||
'processing': '#3b82f6', // Blue - currently encoding
|
||
'completed': '#10b981', // Green - successfully encoded
|
||
'failed': '#ef4444', // Red - encoding failed
|
||
'skipped': '#94a3b8' // Light gray - skipped (no subtitles)
|
||
};
|
||
|
||
const statusIcon = file.state === 'completed' ? '✅' :
|
||
file.state === 'failed' ? '❌' :
|
||
file.state === 'processing' ? '⏳' :
|
||
file.state === 'skipped' ? '⏭️' : '⏸️';
|
||
|
||
const disableCheckbox = file.state === 'processing' ? 'disabled' : '';
|
||
|
||
// Create tooltip for state badge
|
||
let stateTooltip = '';
|
||
if (file.state === 'discovered') {
|
||
stateTooltip = 'Discovered - select to encode';
|
||
} else if (file.state === 'pending') {
|
||
stateTooltip = 'Selected - will encode when you click Encode Selected';
|
||
} else if (file.state === 'processing') {
|
||
stateTooltip = 'Currently encoding...';
|
||
} else if (file.state === 'completed' && file.encoder_used) {
|
||
stateTooltip = `Completed with ${file.encoder_used}`;
|
||
} else if (file.state === 'failed' && file.error_message) {
|
||
stateTooltip = `Failed: ${file.error_message}`;
|
||
} else if (file.state === 'skipped' && file.error_message) {
|
||
stateTooltip = `Skipped: ${file.error_message}`;
|
||
}
|
||
|
||
const rowClass = file.state === 'processing' ? 'row-processing' : '';
|
||
|
||
// Escape all user-controlled content
|
||
const escapedPath = escapeHtml(file.relative_path);
|
||
const escapedPathAttr = escapeAttr(file.relative_path);
|
||
const escapedState = escapeHtml(file.state);
|
||
const escapedTooltip = escapeAttr(stateTooltip);
|
||
|
||
html += `
|
||
<tr class="${rowClass}" data-file-id="${file.id}" style="border-bottom: 1px solid #334155; transition: all 0.2s ease;">
|
||
<td style="padding: 12px; text-align: center;">
|
||
<input type="checkbox" class="file-checkbox" data-file-id="${file.id}"
|
||
onchange="toggleFileSelection(${file.id})"
|
||
${disableCheckbox}
|
||
style="cursor: ${file.state === 'processing' ? 'not-allowed' : 'pointer'}; transform: scale(1.2);">
|
||
</td>
|
||
<td style="padding: 12px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapedPathAttr}">
|
||
<span style="font-weight: 500;">${escapedPath}</span>
|
||
</td>
|
||
<td style="padding: 12px;">
|
||
<span style="background: ${stateBadgeColors[file.state] || '#64748b'}; padding: 6px 12px; border-radius: 6px; font-size: 0.85em; cursor: help; font-weight: 600;"
|
||
title="${escapedTooltip}">
|
||
${escapedState}
|
||
</span>
|
||
</td>
|
||
<td style="padding: 12px; color: #94a3b8;">-</td>
|
||
<td style="padding: 12px; font-weight: 500;">${file.original_size ? formatBytes(file.original_size) : '-'}</td>
|
||
<td style="padding: 12px; font-weight: 500;">${file.encoded_size ? formatBytes(file.encoded_size) : '-'}</td>
|
||
<td style="padding: 12px; font-weight: 600; color: ${savings !== '-' && parseFloat(savings) > 0 ? '#10b981' : '#64748b'};">
|
||
${savings !== '-' ? escapeHtml(savings) + '%' : '-'}
|
||
</td>
|
||
<td style="padding: 12px; font-size: 1.2em;">${statusIcon}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// Check if we got fewer files than requested (means no more data)
|
||
if (files.length < 100) {
|
||
hasMoreFiles = false;
|
||
// Remove loading indicator if it exists
|
||
const loadMoreRow = document.getElementById('loadMoreRow');
|
||
if (loadMoreRow) {
|
||
loadMoreRow.remove();
|
||
}
|
||
} else {
|
||
hasMoreFiles = true;
|
||
// Add "Load More" button at the end
|
||
html += `
|
||
<tr id="loadMoreRow">
|
||
<td colspan="8" style="text-align: center; padding: 20px;">
|
||
<button class="btn" onclick="loadMoreFiles()" style="background: #3b82f6; color: white; padding: 12px 24px;">
|
||
📥 Load More Files
|
||
</button>
|
||
<div style="margin-top: 10px; color: #64748b; font-size: 0.9em;">
|
||
Showing ${currentOffset + files.length} files
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
// Append or replace content
|
||
if (append) {
|
||
// Remove existing load more button
|
||
const loadMoreRow = document.getElementById('loadMoreRow');
|
||
if (loadMoreRow) {
|
||
loadMoreRow.remove();
|
||
}
|
||
// Append new rows
|
||
tbody.innerHTML += html;
|
||
} else {
|
||
tbody.innerHTML = html;
|
||
document.getElementById('selectAll').checked = false;
|
||
}
|
||
|
||
// Update offset for next load
|
||
currentOffset += files.length;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load file quality:', error);
|
||
if (!append) {
|
||
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 40px; color: #ef4444;">Error loading files</td></tr>';
|
||
}
|
||
} finally {
|
||
isLoadingMore = false;
|
||
}
|
||
}
|
||
|
||
function loadMoreFiles() {
|
||
loadFileQuality(true); // Pass true to append instead of replace
|
||
}
|
||
|
||
function handleScroll() {
|
||
// Check if user scrolled near the bottom
|
||
const scrollPosition = window.innerHeight + window.scrollY;
|
||
const documentHeight = document.documentElement.scrollHeight;
|
||
|
||
// Load more when within 500px of bottom
|
||
if (scrollPosition >= documentHeight - 500) {
|
||
if (hasMoreFiles && !isLoadingMore) {
|
||
loadFileQuality(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleFileSelection(fileId) {
|
||
if (selectedFiles.has(fileId)) {
|
||
selectedFiles.delete(fileId);
|
||
} else {
|
||
selectedFiles.add(fileId);
|
||
}
|
||
updateSelectedCount();
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
const selectAll = document.getElementById('selectAll');
|
||
const checkboxes = document.querySelectorAll('.file-checkbox:not([disabled])');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const fileId = parseInt(checkbox.getAttribute('data-file-id'));
|
||
checkbox.checked = selectAll.checked;
|
||
|
||
if (selectAll.checked) {
|
||
selectedFiles.add(fileId);
|
||
} else {
|
||
selectedFiles.delete(fileId);
|
||
}
|
||
});
|
||
|
||
updateSelectedCount();
|
||
}
|
||
|
||
function updateSelectedCount() {
|
||
const count = selectedFiles.size;
|
||
const countText = count === 0 ? 'No files selected' : `${count} file${count !== 1 ? 's' : ''} selected`;
|
||
document.getElementById('selectedCount').textContent = countText;
|
||
|
||
const btnEncode = document.getElementById('btnEncode');
|
||
const encodeProfile = document.getElementById('reencodeProfile');
|
||
const selectionBanner = document.getElementById('selectionBanner');
|
||
|
||
// Update button state
|
||
btnEncode.disabled = count === 0 || !encodeProfile.value;
|
||
|
||
// Show/hide selection banner
|
||
if (count > 0) {
|
||
selectionBanner.style.display = 'block';
|
||
} else {
|
||
selectionBanner.style.display = 'none';
|
||
}
|
||
|
||
// Highlight selected rows
|
||
document.querySelectorAll('.file-checkbox').forEach(checkbox => {
|
||
const row = checkbox.closest('tr');
|
||
const fileId = parseInt(checkbox.getAttribute('data-file-id'));
|
||
if (selectedFiles.has(fileId)) {
|
||
row.classList.add('row-selected');
|
||
} else {
|
||
row.classList.remove('row-selected');
|
||
}
|
||
});
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedFiles.clear();
|
||
document.querySelectorAll('.file-checkbox').forEach(checkbox => {
|
||
checkbox.checked = false;
|
||
});
|
||
document.getElementById('selectAll').checked = false;
|
||
updateSelectedCount();
|
||
}
|
||
|
||
function applyFilter(filterType) {
|
||
// Update current filter
|
||
currentAttributeFilter = filterType === 'all' ? null : filterType;
|
||
|
||
// Update button styles to show active filter
|
||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||
const btnFilter = btn.getAttribute('data-filter');
|
||
if (btnFilter === filterType) {
|
||
btn.style.background = '#3b82f6';
|
||
btn.style.fontWeight = '600';
|
||
} else {
|
||
btn.style.background = '#64748b';
|
||
btn.style.fontWeight = '400';
|
||
}
|
||
});
|
||
|
||
// Reload the file list with the new filter
|
||
loadFileQuality();
|
||
}
|
||
|
||
function filterFileTable() {
|
||
const searchTerm = document.getElementById('fileSearch').value.toLowerCase();
|
||
const rows = document.querySelectorAll('#qualityTableBody tr');
|
||
|
||
rows.forEach(row => {
|
||
const filename = row.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || '';
|
||
if (filename.includes(searchTerm)) {
|
||
row.style.display = '';
|
||
} else {
|
||
row.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
function quickSelect(state) {
|
||
// Clear current selection
|
||
selectedFiles.clear();
|
||
|
||
// Select all files with the specified state
|
||
document.querySelectorAll('#qualityTableBody tr').forEach(row => {
|
||
const stateBadge = row.querySelector('td:nth-child(3) span');
|
||
const checkbox = row.querySelector('.file-checkbox');
|
||
|
||
if (stateBadge && checkbox && !checkbox.disabled) {
|
||
const fileState = stateBadge.textContent.trim();
|
||
if (fileState === state) {
|
||
const fileId = parseInt(checkbox.getAttribute('data-file-id'));
|
||
selectedFiles.add(fileId);
|
||
checkbox.checked = true;
|
||
} else {
|
||
checkbox.checked = false;
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('selectAll').checked = false;
|
||
updateSelectedCount();
|
||
}
|
||
|
||
async function encodeSelected() {
|
||
console.log('encodeSelected() called');
|
||
const profile = document.getElementById('reencodeProfile').value;
|
||
console.log('Selected profile:', profile);
|
||
console.log('Selected files:', Array.from(selectedFiles));
|
||
|
||
if (!profile) {
|
||
alert('Please select an encoding profile');
|
||
return;
|
||
}
|
||
|
||
if (selectedFiles.size === 0) {
|
||
alert('Please select at least one file');
|
||
return;
|
||
}
|
||
|
||
const fileIds = Array.from(selectedFiles);
|
||
const confirmMsg = `Encode ${fileIds.length} file(s) using profile "${profile}"?\n\nThis will start encoding immediately.`;
|
||
|
||
if (!confirm(confirmMsg)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Debug: Log CSRF token
|
||
console.log('CSRF Token:', csrfToken);
|
||
|
||
// First, queue the selected files
|
||
const queueResponse = await fetchWithCsrf('/api/jobs/reencode-selected', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
file_ids: fileIds,
|
||
profile: profile
|
||
})
|
||
});
|
||
|
||
console.log('Queue response status:', queueResponse.status);
|
||
|
||
if (!queueResponse.ok) {
|
||
const errorText = await queueResponse.text();
|
||
console.error('Queue failed:', errorText);
|
||
alert(`Failed to queue files (${queueResponse.status}): ${errorText}`);
|
||
return;
|
||
}
|
||
|
||
const queueResult = await queueResponse.json();
|
||
|
||
if (!queueResult.success) {
|
||
alert('❌ Failed to queue files: ' + queueResult.error);
|
||
return;
|
||
}
|
||
|
||
// Then immediately start processing
|
||
const startResponse = await fetchWithCsrf('/api/jobs/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ profile: profile })
|
||
});
|
||
|
||
const startResult = await startResponse.json();
|
||
|
||
if (startResult.success) {
|
||
alert(`✅ Encoding started for ${fileIds.length} file(s)!`);
|
||
selectedFiles.clear();
|
||
updateSelectedCount();
|
||
loadFileQuality();
|
||
} else {
|
||
alert('⚠️ Files queued but failed to start encoding: ' + startResult.message);
|
||
}
|
||
} catch (error) {
|
||
alert('Error starting encoding: ' + error.message);
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|