diff --git a/lib/config-store.js b/lib/config-store.js
index c990f92..14e3a01 100644
--- a/lib/config-store.js
+++ b/lib/config-store.js
@@ -81,13 +81,12 @@ class ConfigStore {
save(config) {
const current = this.load();
- // Update hosters credentials
if (config.hosters) current.hosters = config.hosters;
- // Update hoster settings
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
- // Update global settings
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() {
diff --git a/main.js b/main.js
index 273259f..5d6b7fa 100644
--- a/main.js
+++ b/main.js
@@ -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) {
- 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
? requestedHosters
: allowed;
@@ -290,6 +324,15 @@ async function runHosterHealthCheck(config, requestedHosters) {
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' };
} catch (err) {
return {
@@ -429,7 +472,10 @@ ipcMain.handle('start-upload', (_event, payload) => {
uploadManager = new UploadManager(config.hosterSettings || {});
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()) {
mainWindow.webContents.send('upload-progress', data);
}
diff --git a/package.json b/package.json
index e910d82..de46cbe 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
- "version": "1.3.0",
+ "version": "1.4.0",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
diff --git a/renderer/app.js b/renderer/app.js
index 7d9a2a5..2300323 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -15,6 +15,8 @@ const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
// Queue state
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 queueSortState = { key: 'filename', direction: 'asc' };
@@ -288,9 +290,11 @@ async function persistQueueStateNow() {
function persistQueueStateSoon() {
clearTimeout(queuePersistTimer);
+ // Use longer debounce during uploads to reduce disk I/O
+ const delay = uploading ? 3000 : 500;
queuePersistTimer = setTimeout(() => {
persistQueueStateNow().catch(() => {});
- }, 250);
+ }, delay);
}
function clearPersistedQueueStateSoon() {
@@ -395,96 +399,177 @@ function buildQueuePreview() {
const hosters = getSelectedHosters();
if (hosters.length === 0) {
queueJobs = queueJobs.filter(j => j.status !== 'preview');
+ rebuildJobIndex();
renderQueueTable();
persistQueueStateSoon();
return;
}
- // Remove old preview jobs (status 'preview')
+ // Remove old preview jobs
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 hoster of hosters) {
- // Don't add if already in queue (from a previous upload)
- const exists = queueJobs.find(j => j.file === file.path && j.hoster === hoster && j.status !== 'error');
- if (!exists) {
- queueJobs.push({
+ const key = `${file.path}|${hoster}`;
+ if (!existingKeys.has(key)) {
+ const job = {
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: file.path, fileName: file.name, hoster,
status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0,
speedKbs: 0, elapsed: 0, remaining: 0,
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
- });
+ };
+ queueJobs.push(job);
+ existingKeys.add(key);
}
}
}
+ rebuildJobIndex();
renderQueueTable();
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 _sortedJobsCache = [];
+const VIRTUAL_ROW_HEIGHT = 28;
+const VIRTUAL_OVERSCAN = 10;
+let _lastVisibleRange = { start: -1, end: -1 };
+let _queueListenersBound = false;
+
function scheduleQueueRender() {
if (_renderQueued) return;
_renderQueued = true;
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 `
+ | ${escapeHtml(job.fileName)} |
+ ${uploadedSize} |
+ ${escapeHtml(job.hoster)} |
+ ${statusText} |
+ ${elapsed} |
+ ${remaining} |
+ ${speed} |
+
+
+
+ ${job.status === 'preview' ? '' : pct + '%'}
+
+ |
+
`;
+}
+
function renderQueueTable() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
- // Preserve scroll position
- const scrollContainer = document.getElementById('queueContainer');
- const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
+ _sortedJobsCache = sortQueueJobs(queueJobs);
+ const totalRows = _sortedJobsCache.length;
- 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) => {
- 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 `
- | ${escapeHtml(job.fileName)} |
- ${uploadedSize} |
- ${escapeHtml(job.hoster)} |
- ${statusText} |
- ${elapsed} |
- ${remaining} |
- ${speed} |
-
-
-
- ${job.status === 'preview' ? '' : pct + '%'}
-
- |
-
`;
- }).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));
- });
+ // Bind event delegation once
+ if (!_queueListenersBound) {
+ _queueListenersBound = true;
+ tbody.addEventListener('click', (e) => {
+ const row = e.target.closest('.queue-row');
+ if (row) handleRowClick(e, row);
+ });
+ tbody.addEventListener('contextmenu', (e) => {
+ const row = e.target.closest('.queue-row');
+ if (row) handleRowContextMenu(e, row);
+ });
+ }
// Update retry button visibility
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
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 += ` |
`;
+ for (let i = startIdx; i < endIdx; i++) {
+ html += buildRowHtml(_sortedJobsCache[i]);
+ }
+ if (bottomPad > 0) html += ` |
`;
+
+ tbody.innerHTML = html;
+}
+
+function _onQueueScroll() {
+ if (_sortedJobsCache.length >= 200) {
+ const tbody = document.getElementById('queueBody');
+ if (tbody) _renderVirtualRows(tbody);
+ }
+}
+
function sortQueueJobs(jobs) {
const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1;
@@ -656,7 +741,7 @@ async function startUpload() {
// Auto health check
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) {
healthCheckRunning = true;
try {
@@ -711,20 +796,20 @@ async function cancelUpload() {
// --- Progress handling ---
function handleProgress(data) {
- console.log('[upload-progress]', data.status, data.hoster, data.fileName, data.error || '');
- // Find matching job by fileName + hoster, or by uploadId
- let job = data.uploadId ? queueJobs.find(j => j.uploadId === data.uploadId) : null;
+ // Find matching job: O(1) by uploadId, fallback to linear search
+ let job = data.uploadId ? _jobIndexByUploadId.get(data.uploadId) : null;
if (!job) {
- // Match by file+hoster for queued/preview jobs (prefer queued, then preview)
job = queueJobs.find(j =>
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
) || queueJobs.find(j =>
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) {
- // Create new job entry
job = {
id: data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadId: data.uploadId,
@@ -734,6 +819,7 @@ function handleProgress(data) {
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
};
queueJobs.push(job);
+ indexJob(job);
}
// Update job state
@@ -792,7 +878,7 @@ function handleBatchDone(summary) {
}
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('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0);
document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0);
@@ -854,9 +940,9 @@ async function executeHealthCheck(hosters, mode) {
async function runHealthCheck() {
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) {
- 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; }
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
document.querySelectorAll('#queueTable th.sortable').forEach(th => {
th.addEventListener('click', () => {
diff --git a/renderer/index.html b/renderer/index.html
index 6dd44e6..80aac40 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -164,8 +164,10 @@
Upload Einstellungen
Upload-Einstellungen pro Hoster. Zugangsdaten werden im Accounts-Tab verwaltet.
-
-
+
+
+
+
diff --git a/renderer/styles.css b/renderer/styles.css
index 12a96ec..1bd74e7 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -227,6 +227,8 @@ body {
white-space: nowrap;
}
+.virtual-spacer td { padding: 0 !important; border: none !important; }
+
/* Queue Row States */
.queue-row { transition: background 0.15s; cursor: pointer; }
.queue-row:hover { background: rgba(255, 255, 255, 0.04); }
@@ -569,7 +571,8 @@ body {
}
.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 {
display: grid;