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;