video_press/static/js/modules/scan.js

194 lines
7.2 KiB
JavaScript

/**
* scan.js
* -------
* Directory scan and file selection table.
*
* Handles the "Scan for Files" button, the /api/scan fetch, rendering the
* results table, and the select-all / deselect-all controls.
*
* Exports
* -------
* initScan() — attach all event listeners; call once at startup
*/
import { state, els, announce } from './state.js';
import { esc } from './utils.js';
// ─── Status helper ────────────────────────────────────────────────────────────
function setScanStatus(msg, type) {
els.scanStatus.textContent = msg;
els.scanStatus.style.color =
type === 'error' ? 'var(--text-danger)'
: type === 'success' ? 'var(--text-success)'
: type === 'warn' ? 'var(--accent)'
: 'var(--text-muted)';
}
// ─── File table ───────────────────────────────────────────────────────────────
function updateSelectionUI() {
els.selectionSummary.textContent =
`${state.selectedPaths.size} of ${state.scannedFiles.length} selected`;
els.compressBtn.disabled = state.selectedPaths.size === 0;
}
/**
* Build and inject the file selection table from the scan results.
* Attaches checkbox change handlers for each row.
* @param {Array} files — enriched file objects from /api/scan
*/
export function renderFileTable(files) {
let html = '';
files.forEach((f, idx) => {
const codec = (f.codec || 'unknown').toLowerCase();
const isHevc = ['hevc', 'h265', 'x265'].includes(codec);
const isH264 = ['h264', 'avc', 'x264'].includes(codec);
const codecLabel = isHevc ? 'H.265 / HEVC'
: isH264 ? 'H.264 / AVC'
: codec.toUpperCase();
const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : '';
const pathDir = f.path.replace(f.name, '');
html += `
<tr id="row-${idx}" data-path="${esc(f.path)}">
<td class="col-check">
<input type="checkbox" class="file-checkbox" id="chk-${idx}"
data-path="${esc(f.path)}"
aria-label="Select ${esc(f.name)} for compression" />
</td>
<td class="col-name">
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
<span class="file-name">${esc(f.name)}</span>
<span class="file-path" title="${esc(f.path)}">${esc(pathDir)}</span>
</label>
</td>
<td class="col-size"><strong>${f.size_gb.toFixed(3)}</strong> GB</td>
<td class="col-bitrate">
<span class="bitrate-badge">
${esc(f.bit_rate_mbps ? f.bit_rate_mbps + ' Mbps' : 'Unknown')}
</span>
</td>
<td class="col-target">
<span class="bitrate-badge target">
${esc(f.target_bit_rate_mbps ? f.target_bit_rate_mbps + ' Mbps' : '—')}
</span>
</td>
<td class="col-codec">
<span class="codec-tag ${codecMod}"
title="Encoder: ${isHevc ? 'libx265' : 'libx264'}">
${esc(codecLabel)}
</span>
</td>
</tr>`;
});
els.fileTbody.innerHTML = html;
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.addEventListener('change', () => {
const row = chk.closest('tr');
if (chk.checked) {
state.selectedPaths.add(chk.dataset.path);
row.classList.add('selected');
} else {
state.selectedPaths.delete(chk.dataset.path);
row.classList.remove('selected');
}
updateSelectionUI();
});
});
updateSelectionUI();
}
// ─── Public init ─────────────────────────────────────────────────────────────
/**
* Attach event listeners for the scan button and file selection controls.
* Call once during app initialisation.
*/
export function initScan() {
// ── Scan button ──────────────────────────────────────────────────────────
els.scanBtn.addEventListener('click', async () => {
const directory = els.dirInput.value.trim();
const minSize = parseFloat(els.minSizeInput.value);
if (!directory) {
setScanStatus('Please enter a directory path.', 'error');
els.dirInput.focus();
return;
}
if (isNaN(minSize) || minSize <= 0) {
setScanStatus('Please enter a valid minimum size > 0.', 'error');
els.minSizeInput.focus();
return;
}
els.scanBtn.disabled = true;
els.scanBtn.textContent = '⟳ Scanning…';
setScanStatus('Scanning directory, please wait…', 'info');
announce('Scanning directory for video files.');
els.sectionFiles.hidden = true;
try {
const resp = await fetch('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, min_size_gb: minSize }),
});
const data = await resp.json();
if (!resp.ok) {
setScanStatus(`Error: ${data.error}`, 'error');
announce(`Scan failed: ${data.error}`);
return;
}
state.scannedFiles = data.files;
state.selectedPaths.clear();
if (!data.files.length) {
setScanStatus(`No video files larger than ${minSize} GB found.`, 'warn');
announce('No video files found matching your criteria.');
return;
}
setScanStatus(`Found ${data.files.length} file(s).`, 'success');
announce(`Scan complete. Found ${data.files.length} video files.`);
renderFileTable(data.files);
els.sectionFiles.hidden = false;
els.sectionFiles.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
setScanStatus(`Network error: ${err.message}`, 'error');
} finally {
els.scanBtn.disabled = false;
els.scanBtn.innerHTML =
'<span class="btn-icon-prefix" aria-hidden="true">⊙</span> Scan for Files';
}
});
// ── Select all ───────────────────────────────────────────────────────────
els.selectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = true;
state.selectedPaths.add(chk.dataset.path);
chk.closest('tr').classList.add('selected');
});
updateSelectionUI();
announce(`All ${state.scannedFiles.length} files selected.`);
});
// ── Deselect all ─────────────────────────────────────────────────────────
els.deselectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = false;
state.selectedPaths.delete(chk.dataset.path);
chk.closest('tr').classList.remove('selected');
});
updateSelectionUI();
announce('All files deselected.');
});
}