Files
encoderPro/templates/dashboard.html.backup
2026-01-24 17:43:28 -05:00

1829 lines
80 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// 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>