feat: byse.sx health check + performance optimizations for large queues

- Add byse.sx health check via API upload/server endpoint
- Virtual scrolling for queue table (>200 rows renders only visible rows)
- O(1) job lookups via index Maps instead of O(n) array.find()
- Event delegation on queue tbody instead of per-row listeners
- Async config writes to avoid blocking main process
- Increase persist debounce to 3s during uploads (was 250ms)
- Reduce debug logging to state changes only
- Move save button to bottom-right in settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-11 00:45:09 +01:00
parent 294f6a4de7
commit 7d992206e8
6 changed files with 212 additions and 72 deletions

View File

@ -81,13 +81,12 @@ class ConfigStore {
save(config) { save(config) {
const current = this.load(); const current = this.load();
// Update hosters credentials
if (config.hosters) current.hosters = config.hosters; if (config.hosters) current.hosters = config.hosters;
// Update hoster settings
if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
// Update global settings
if (config.globalSettings) current.globalSettings = config.globalSettings; if (config.globalSettings) current.globalSettings = config.globalSettings;
fs.writeFileSync(this.filePath, JSON.stringify(current, null, 2), 'utf-8'); // Async write to avoid blocking main process
const data = JSON.stringify(current, null, 2);
fs.writeFile(this.filePath, data, 'utf-8', () => {});
} }
loadHistory() { loadHistory() {

50
main.js
View File

@ -245,8 +245,42 @@ async function checkVoeHealth(hosterConfig) {
}; };
} }
async function checkByseHealth(hosterConfig) {
const apiKey = hosterConfig && hosterConfig.apiKey
? String(hosterConfig.apiKey).trim()
: '';
if (!apiKey) {
return { status: 'error', message: 'API Key fehlt' };
}
const apiBase = 'https://api.byse.sx';
const serverRes = await fetch(`${apiBase}/upload/server?key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
redirect: 'follow'
});
const serverPayload = await serverRes.json().catch(() => null);
if (!serverPayload || typeof serverPayload !== 'object') {
return { status: 'error', message: 'API lieferte kein gueltiges JSON' };
}
const serverResult = serverPayload.result;
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
}
const msg = String(serverPayload.msg || serverPayload.message || '').trim();
if (msg) {
return { status: 'error', message: msg };
}
return { status: 'error', message: 'API Key ungueltig oder Server nicht erreichbar' };
}
async function runHosterHealthCheck(config, requestedHosters) { async function runHosterHealthCheck(config, requestedHosters) {
const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx']; const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx'];
const source = Array.isArray(requestedHosters) && requestedHosters.length > 0 const source = Array.isArray(requestedHosters) && requestedHosters.length > 0
? requestedHosters ? requestedHosters
: allowed; : allowed;
@ -290,6 +324,15 @@ async function runHosterHealthCheck(config, requestedHosters) {
return { hoster, ...result }; return { hoster, ...result };
} }
if (hoster === 'byse.sx') {
const result = await withTimeout(
checkByseHealth(hosterConfig),
HEALTH_CHECK_TIMEOUT,
'Byse-Check'
);
return { hoster, ...result };
}
return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
} catch (err) { } catch (err) {
return { return {
@ -429,7 +472,10 @@ ipcMain.handle('start-upload', (_event, payload) => {
uploadManager = new UploadManager(config.hosterSettings || {}); uploadManager = new UploadManager(config.hosterSettings || {});
uploadManager.on('progress', (data) => { uploadManager.on('progress', (data) => {
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); // Only log state changes, not continuous progress updates
if (data.status !== 'uploading') {
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
}
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-progress', data); mainWindow.webContents.send('upload-progress', data);
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "1.3.0", "version": "1.4.0",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -15,6 +15,8 @@ const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
// Queue state // Queue state
let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link } let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link }
let _jobIndexById = new Map(); // id -> job (O(1) lookup)
let _jobIndexByUploadId = new Map(); // uploadId -> job
let selectedJobIds = new Set(); let selectedJobIds = new Set();
let queueSortState = { key: 'filename', direction: 'asc' }; let queueSortState = { key: 'filename', direction: 'asc' };
@ -288,9 +290,11 @@ async function persistQueueStateNow() {
function persistQueueStateSoon() { function persistQueueStateSoon() {
clearTimeout(queuePersistTimer); clearTimeout(queuePersistTimer);
// Use longer debounce during uploads to reduce disk I/O
const delay = uploading ? 3000 : 500;
queuePersistTimer = setTimeout(() => { queuePersistTimer = setTimeout(() => {
persistQueueStateNow().catch(() => {}); persistQueueStateNow().catch(() => {});
}, 250); }, delay);
} }
function clearPersistedQueueStateSoon() { function clearPersistedQueueStateSoon() {
@ -395,96 +399,177 @@ function buildQueuePreview() {
const hosters = getSelectedHosters(); const hosters = getSelectedHosters();
if (hosters.length === 0) { if (hosters.length === 0) {
queueJobs = queueJobs.filter(j => j.status !== 'preview'); queueJobs = queueJobs.filter(j => j.status !== 'preview');
rebuildJobIndex();
renderQueueTable(); renderQueueTable();
persistQueueStateSoon(); persistQueueStateSoon();
return; return;
} }
// Remove old preview jobs (status 'preview') // Remove old preview jobs
queueJobs = queueJobs.filter(j => j.status !== 'preview'); queueJobs = queueJobs.filter(j => j.status !== 'preview');
// Build a Set for fast existence checks
const existingKeys = new Set();
for (const j of queueJobs) {
if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`);
}
for (const file of selectedFiles) { for (const file of selectedFiles) {
for (const hoster of hosters) { for (const hoster of hosters) {
// Don't add if already in queue (from a previous upload) const key = `${file.path}|${hoster}`;
const exists = queueJobs.find(j => j.file === file.path && j.hoster === hoster && j.status !== 'error'); if (!existingKeys.has(key)) {
if (!exists) { const job = {
queueJobs.push({
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: file.path, fileName: file.name, hoster, file: file.path, fileName: file.name, hoster,
status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0, status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0,
speedKbs: 0, elapsed: 0, remaining: 0, speedKbs: 0, elapsed: 0, remaining: 0,
error: null, result: null, attempt: 0, maxAttempts: 0, link: '' error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
}); };
queueJobs.push(job);
existingKeys.add(key);
} }
} }
} }
rebuildJobIndex();
renderQueueTable(); renderQueueTable();
persistQueueStateSoon(); persistQueueStateSoon();
} }
// --- Queue Table Rendering (debounced) --- // --- Job Index Management ---
function rebuildJobIndex() {
_jobIndexById.clear();
_jobIndexByUploadId.clear();
for (const job of queueJobs) {
_jobIndexById.set(job.id, job);
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
}
}
function indexJob(job) {
_jobIndexById.set(job.id, job);
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
}
function removeJobFromIndex(job) {
_jobIndexById.delete(job.id);
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId);
}
// --- Queue Table Rendering (debounced with virtual scrolling) ---
let _renderQueued = false; let _renderQueued = false;
let _sortedJobsCache = [];
const VIRTUAL_ROW_HEIGHT = 28;
const VIRTUAL_OVERSCAN = 10;
let _lastVisibleRange = { start: -1, end: -1 };
let _queueListenersBound = false;
function scheduleQueueRender() { function scheduleQueueRender() {
if (_renderQueued) return; if (_renderQueued) return;
_renderQueued = true; _renderQueued = true;
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
} }
function buildRowHtml(job) {
const statusClass = `status-${job.status}`;
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
<td class="col-size">${uploadedSize}</td>
<td class="col-host">${escapeHtml(job.hoster)}</td>
<td class="col-status"><span class="status-badge ${statusClass}">${statusText}</span></td>
<td class="col-elapsed">${elapsed}</td>
<td class="col-remaining">${remaining}</td>
<td class="col-speed">${speed}</td>
<td class="col-progress">
<div class="progress-cell">
<div class="progress-bar-bg">
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
</div>
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
</div>
</td>
</tr>`;
}
function renderQueueTable() { function renderQueueTable() {
const tbody = document.getElementById('queueBody'); const tbody = document.getElementById('queueBody');
if (!tbody) return; if (!tbody) return;
// Preserve scroll position _sortedJobsCache = sortQueueJobs(queueJobs);
const scrollContainer = document.getElementById('queueContainer'); const totalRows = _sortedJobsCache.length;
const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
const sorted = sortQueueJobs(queueJobs); // For small queues (<200 rows), use simple rendering
if (totalRows < 200) {
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
} else {
// Virtual scrolling for large queues
_renderVirtualRows(tbody);
}
tbody.innerHTML = sorted.map((job) => { // Bind event delegation once
const statusClass = `status-${job.status}`; if (!_queueListenersBound) {
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; _queueListenersBound = true;
const uploadedSize = job.status === 'preview' tbody.addEventListener('click', (e) => {
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') const row = e.target.closest('.queue-row');
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; if (row) handleRowClick(e, row);
const statusText = getStatusText(job); });
const elapsed = formatTime(job.elapsed); tbody.addEventListener('contextmenu', (e) => {
const remaining = formatTime(job.remaining); const row = e.target.closest('.queue-row');
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; if (row) handleRowContextMenu(e, row);
const pct = Math.min(100, Math.round((job.progress || 0) * 100)); });
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; }
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}">
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
<td class="col-size">${uploadedSize}</td>
<td class="col-host">${escapeHtml(job.hoster)}</td>
<td class="col-status"><span class="status-badge ${statusClass}">${statusText}</span></td>
<td class="col-elapsed">${elapsed}</td>
<td class="col-remaining">${remaining}</td>
<td class="col-speed">${speed}</td>
<td class="col-progress">
<div class="progress-cell">
<div class="progress-bar-bg">
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
</div>
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
</div>
</td>
</tr>`;
}).join('');
// Restore scroll position
if (scrollContainer) scrollContainer.scrollTop = scrollTop;
// Attach click handlers
tbody.querySelectorAll('.queue-row').forEach(row => {
row.addEventListener('click', (e) => handleRowClick(e, row));
row.addEventListener('contextmenu', (e) => handleRowContextMenu(e, row));
});
// Update retry button visibility // Update retry button visibility
const hasFailedJobs = queueJobs.some(j => j.status === 'error'); const hasFailedJobs = queueJobs.some(j => j.status === 'error');
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
} }
function _renderVirtualRows(tbody) {
const scrollContainer = document.getElementById('queueContainer');
if (!scrollContainer) return;
const totalRows = _sortedJobsCache.length;
const totalHeight = totalRows * VIRTUAL_ROW_HEIGHT;
const scrollTop = scrollContainer.scrollTop;
const viewportHeight = scrollContainer.clientHeight;
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
// Only re-render if visible range changed
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) return;
_lastVisibleRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT;
let html = '';
if (topPad > 0) html += `<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="8"></td></tr>`;
for (let i = startIdx; i < endIdx; i++) {
html += buildRowHtml(_sortedJobsCache[i]);
}
if (bottomPad > 0) html += `<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="8"></td></tr>`;
tbody.innerHTML = html;
}
function _onQueueScroll() {
if (_sortedJobsCache.length >= 200) {
const tbody = document.getElementById('queueBody');
if (tbody) _renderVirtualRows(tbody);
}
}
function sortQueueJobs(jobs) { function sortQueueJobs(jobs) {
const { key, direction } = queueSortState; const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1; const factor = direction === 'asc' ? 1 : -1;
@ -656,7 +741,7 @@ async function startUpload() {
// Auto health check // Auto health check
if (autoHealthCheckEnabled) { if (autoHealthCheckEnabled) {
const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx'); const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx');
if (checkHosters.length > 0) { if (checkHosters.length > 0) {
healthCheckRunning = true; healthCheckRunning = true;
try { try {
@ -711,20 +796,20 @@ async function cancelUpload() {
// --- Progress handling --- // --- Progress handling ---
function handleProgress(data) { function handleProgress(data) {
console.log('[upload-progress]', data.status, data.hoster, data.fileName, data.error || ''); // Find matching job: O(1) by uploadId, fallback to linear search
// Find matching job by fileName + hoster, or by uploadId let job = data.uploadId ? _jobIndexByUploadId.get(data.uploadId) : null;
let job = data.uploadId ? queueJobs.find(j => j.uploadId === data.uploadId) : null;
if (!job) { if (!job) {
// Match by file+hoster for queued/preview jobs (prefer queued, then preview)
job = queueJobs.find(j => job = queueJobs.find(j =>
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
) || queueJobs.find(j => ) || queueJobs.find(j =>
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview' j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
); );
if (job && data.uploadId) job.uploadId = data.uploadId; if (job && data.uploadId) {
job.uploadId = data.uploadId;
_jobIndexByUploadId.set(data.uploadId, job);
}
} }
if (!job) { if (!job) {
// Create new job entry
job = { job = {
id: data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadId: data.uploadId, uploadId: data.uploadId,
@ -734,6 +819,7 @@ function handleProgress(data) {
error: null, result: null, attempt: 0, maxAttempts: 0, link: '' error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
}; };
queueJobs.push(job); queueJobs.push(job);
indexJob(job);
} }
// Update job state // Update job state
@ -792,7 +878,7 @@ function handleBatchDone(summary) {
} }
function handleStats(data) { function handleStats(data) {
console.log('[upload-stats]', data.state, 'active=' + data.activeJobs); // stats logging removed for perf
document.getElementById('sbState').textContent = data.state === 'uploading' ? 'Upload laeuft...' : 'Bereit'; document.getElementById('sbState').textContent = data.state === 'uploading' ? 'Upload laeuft...' : 'Bereit';
document.getElementById('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0); document.getElementById('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0);
document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0); document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0);
@ -854,9 +940,9 @@ async function executeHealthCheck(hosters, mode) {
async function runHealthCheck() { async function runHealthCheck() {
if (healthCheckRunning || uploading) return; if (healthCheckRunning || uploading) return;
const hosters = getSelectedHosters().filter(n => n === 'doodstream.com' || n === 'vidmoly.me' || n === 'voe.sx'); const hosters = getSelectedHosters().filter(n => n === 'doodstream.com' || n === 'vidmoly.me' || n === 'voe.sx' || n === 'byse.sx');
if (hosters.length === 0) { if (hosters.length === 0) {
const allHosters = ['doodstream.com', 'vidmoly.me', 'voe.sx'].filter(n => hosterHasCredentials(n, config.hosters[n] || {})); const allHosters = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx'].filter(n => hosterHasCredentials(n, config.hosters[n] || {}));
if (allHosters.length === 0) { alert('Keine Hoster mit Zugangsdaten fuer Health-Check.'); return; } if (allHosters.length === 0) { alert('Keine Hoster mit Zugangsdaten fuer Health-Check.'); return; }
hosters.push(...allHosters); hosters.push(...allHosters);
} }
@ -1443,6 +1529,10 @@ function setupListeners() {
}); });
} }
// Virtual scroll for large queues
const queueContainer = document.getElementById('queueContainer');
if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true });
// Queue table sorting // Queue table sorting
document.querySelectorAll('#queueTable th.sortable').forEach(th => { document.querySelectorAll('#queueTable th.sortable').forEach(th => {
th.addEventListener('click', () => { th.addEventListener('click', () => {

View File

@ -164,8 +164,10 @@
<h2>Upload Einstellungen</h2> <h2>Upload Einstellungen</h2>
<p class="settings-hint">Upload-Einstellungen pro Hoster. Zugangsdaten werden im Accounts-Tab verwaltet.</p> <p class="settings-hint">Upload-Einstellungen pro Hoster. Zugangsdaten werden im Accounts-Tab verwaltet.</p>
<div class="settings-hosters" id="settingsHosters"></div> <div class="settings-hosters" id="settingsHosters"></div>
<button class="btn btn-primary" id="saveSettingsBtn">Alles Speichern</button> <div class="settings-save-row">
<span class="save-feedback" id="saveFeedback"></span> <span class="save-feedback" id="saveFeedback"></span>
<button class="btn btn-primary" id="saveSettingsBtn">Alles Speichern</button>
</div>
</div> </div>
</div> </div>

View File

@ -227,6 +227,8 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.virtual-spacer td { padding: 0 !important; border: none !important; }
/* Queue Row States */ /* Queue Row States */
.queue-row { transition: background 0.15s; cursor: pointer; } .queue-row { transition: background 0.15s; cursor: pointer; }
.queue-row:hover { background: rgba(255, 255, 255, 0.04); } .queue-row:hover { background: rgba(255, 255, 255, 0.04); }
@ -569,7 +571,8 @@ body {
} }
.toggle-vis:hover { border-color: var(--border-hover); } .toggle-vis:hover { border-color: var(--border-hover); }
.save-feedback { font-size: 12px; color: var(--success); margin-left: 8px; } .settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; }
.save-feedback { font-size: 12px; color: var(--success); }
.settings-grid-mini { .settings-grid-mini {
display: grid; display: grid;