const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; // --- State --- let selectedFiles = []; // { path, name, size } let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let editingAccountHoster = null; // null = adding, string = editing let autoHealthCheckEnabled = true; let queuePersistTimer = null; let settingsSaveTimer = null; let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; 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' }; // History state let historyRowsData = []; let historySortState = { key: 'date', direction: 'desc' }; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; let recentSortState = { key: 'date', direction: 'desc' }; let selectedRecentIds = new Set(); // --- Init --- async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); restoreQueueStateFromConfig(); renderHosterSummary(); renderHosterModal(); renderSettings(); renderAccounts(); setupListeners(); setupDragDrop(); loadHistory(); renderRecentUploadsPanel(); updateUploadView(); updateStatusBar(); // Version display try { const version = await window.api.getVersion(); const versionLabel = document.getElementById('versionLabel'); if (versionLabel) versionLabel.textContent = `v${version}`; } catch {} // Update listeners window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); // Upload event listeners — with debug logging to file window.api.onUploadProgress((data) => { window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || '')); handleProgress(data); }); window.api.onUploadBatchDone((data) => { window.api.debugLog('RX upload-batch-done'); handleBatchDone(data); }); window.api.onUploadStats((data) => { window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs); handleStats(data); }); window.api.onShutdownCountdown(handleShutdownCountdown); // Folder monitor: auto-queue new files window.api.onFolderMonitorNewFiles((files) => { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const fm = config.globalSettings && config.globalSettings.folderMonitor; const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; if (fmHosters.length > 0) { // Pre-selected hosters: set them as active selection and add directly to queue selectedUploadHosters = fmHosters.slice(); const newFiles = []; for (const p of files) { if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } } if (newFiles.length > 0) { selectedFiles.push(...newFiles); buildQueuePreview(); updateUploadView(); if (fm.autoStart && !uploading && !healthCheckRunning) startUpload(); } } else { // No pre-selected hosters: open modal addPathsToQueue(files); } }); window.api.debugLog('init complete, all listeners registered'); // Restore always-on-top state try { const onTop = await window.api.getAlwaysOnTop(); alwaysOnTopState = !!onTop; } catch {} scheduleStartupAccountCheck(); } // --- Tab switching --- document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); tab.classList.add('active'); document.getElementById(`${tab.dataset.view}-view`).classList.add('active'); if (tab.dataset.view === 'history') loadHistory(); }); }); // --- Hoster selection --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); if (name === 'voe.sx' || name === 'doodstream.com') return !!(hoster.username && hoster.password) || !!hoster.apiKey; return !!hoster.apiKey; } function getAvailableHosters() { return HOSTERS .map(name => { const hoster = config.hosters[name] || {}; return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) }; }) .filter(item => item.hasCreds); } function syncSelectedUploadHosters() { const available = new Set(getAvailableHosters().map(item => item.name)); selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); if (selectedUploadHosters.length === 0) { selectedUploadHosters = HOSTERS.filter(name => { const hoster = config.hosters[name] || {}; return !!hoster.enabled && hosterHasCredentials(name, hoster); }); } } function getSelectedHosters() { return selectedUploadHosters.slice(); } function getHosterLabel(name) { const labels = { 'doodstream.com': 'Doodstream', 'voe.sx': 'VOE', 'vidmoly.me': 'Vidmoly', 'byse.sx': 'Byse' }; return labels[name] || name; } function getAccountModeParts(name, hoster) { if (!hoster) return []; const hasLogin = !!(hoster.username && hoster.password); const hasApi = !!hoster.apiKey; if (name === 'vidmoly.me') return hasLogin ? ['Login Web'] : []; if (name === 'byse.sx') return hasApi ? ['API'] : []; const parts = []; if (hasLogin) parts.push('Login Web'); if (hasApi) parts.push('API'); return parts; } function getAccountDisplayName(name, hoster) { const parts = getAccountModeParts(name, hoster); return parts.length > 0 ? `${getHosterLabel(name)} (${parts.join(' + ')})` : getHosterLabel(name); } function maskCredential(value, keep = 4) { const text = String(value || '').trim(); if (!text) return ''; if (text.length <= keep) return text; return `${text.slice(0, keep)}…${text.slice(-2)}`; } function ensureAccountStatusEntries() { const nextStatuses = {}; for (const { name } of getAccountsWithCreds()) { nextStatuses[name] = accountStatuses[name] || { status: 'unchecked', message: '' }; } accountStatuses = nextStatuses; } function scheduleStartupAccountCheck() { const accounts = getAccountsWithCreds(); if (!accounts.length) return; setTimeout(() => { runHealthCheck('startup').catch(() => {}); }, 500); } function renderHosterSummary() { const summary = document.getElementById('hosterSummary'); if (!summary) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { summary.textContent = 'Keine Upload-Ziele ausgewählt'; } else if (hosters.length === 1) { summary.textContent = `Aktives Ziel: ${getAccountDisplayName(hosters[0], config.hosters[hosters[0]] || {})}`; } else { summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; } } function renderHosterModal() { const list = document.getElementById('hosterModalList'); const hint = document.getElementById('hosterModalHint'); if (!list || !hint) return; const available = getAvailableHosters(); if (available.length === 0) { list.innerHTML = ''; hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.'; return; } list.innerHTML = available.map(item => { const checked = selectedUploadHosters.includes(item.name); const h = config.hosters[item.name] || {}; const st = accountStatuses[item.name]; const subtitle = st && st.status === 'ok' ? 'Bereit' : st && st.status === 'warn' ? 'Prüfung mit Warnung' : st && st.status === 'error' ? 'Login-Fehler' : `${getCredentialLabel(item.name, h)} hinterlegt`; return ` `; }).join(''); hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.'; list.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.addEventListener('change', () => { input.closest('.hoster-option')?.classList.toggle('selected', input.checked); }); }); } function openHosterModal() { syncSelectedUploadHosters(); renderHosterModal(); document.getElementById('hosterModal').style.display = 'flex'; } function closeHosterModal() { const modal = document.getElementById('hosterModal'); if (modal) modal.style.display = 'none'; } function applyHosterSelection() { selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); // Move pending files to selectedFiles on confirm if (_pendingFiles.length > 0) { selectedFiles.push(..._pendingFiles); _pendingFiles = []; } renderHosterSummary(); updateUploadView(); persistQueueStateSoon(true); // immediate persist after adding files document.getElementById('hosterModal').style.display = 'none'; } function cancelHosterModal() { _pendingFiles = []; closeHosterModal(); } function normalizeRestoredJobStatus(status) { if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status; return 'queued'; } function restoreQueueStateFromConfig() { if (config?.globalSettings?.resumeQueueOnLaunch === false) return; const pending = config?.globalSettings?.pendingQueue; if (!pending || typeof pending !== 'object') return; selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters.filter(Boolean) : selectedUploadHosters; selectedFiles = Array.isArray(pending.selectedFiles) ? pending.selectedFiles .filter(file => file && file.path) .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; queueJobs = Array.isArray(pending.queueJobs) ? pending.queueJobs .filter(job => job && job.fileName && job.hoster) .map(job => ({ id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: null, file: job.file || '', fileName: job.fileName, hoster: job.hoster, status: normalizeRestoredJobStatus(job.status), bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, bytesTotal: job.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: job.error || null, result: job.result || null, attempt: 0, maxAttempts: job.maxAttempts || 0, link: '', progress: job.status === 'done' ? 1 : 0 })) : []; } function buildPersistedQueueState() { const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { if (job.file && !selectedFileMap.has(job.file)) { selectedFileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); } } if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { return null; } return { selectedUploadHosters: getSelectedHosters(), selectedFiles: Array.from(selectedFileMap.values()), queueJobs: queueJobs.map(job => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster, // Save aborted jobs as queued so they survive restart status: job.status === 'aborted' ? 'queued' : job.status, bytesTotal: job.bytesTotal || 0, error: job.status === 'aborted' ? null : (job.error || null), result: job.result || null, maxAttempts: job.maxAttempts || 0 })) }; } async function persistQueueStateNow() { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: buildPersistedQueueState() }; config.globalSettings = globalSettings; await window.api.saveGlobalSettings(globalSettings); } function persistQueueStateSoon(immediate) { clearTimeout(queuePersistTimer); if (immediate) { persistQueueStateNow().catch(() => {}); return; } // Use longer debounce during uploads to reduce disk I/O const delay = uploading ? 10000 : 500; queuePersistTimer = setTimeout(() => { persistQueueStateNow().catch(() => {}); }, delay); } function clearPersistedQueueStateSoon() { clearTimeout(queuePersistTimer); queuePersistTimer = setTimeout(() => { const globalSettings = { ...(config.globalSettings || {}), pendingQueue: null }; config.globalSettings = globalSettings; window.api.saveGlobalSettings(globalSettings).catch(() => {}); }, 0); } // --- File selection --- function setupDragDrop() { const dropZone = document.getElementById('dropZone'); // Allow drop on the entire upload view const uploadView = document.getElementById('upload-view'); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); addDroppedFiles(e.dataTransfer.files).catch(console.error); }); dropZone.addEventListener('click', () => pickFiles()); // Also handle drops on queue container uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); uploadView.addEventListener('drop', (e) => { e.preventDefault(); if (e.target.closest('.drop-zone')) return; // handled above addDroppedFiles(e.dataTransfer.files).catch(console.error); }); } let _pendingFiles = []; // Files waiting for hoster modal confirmation let _addingDropped = false; async function addDroppedFiles(fileList) { if (_addingDropped) return; _addingDropped = true; try { const files = Array.from(fileList); const existingPaths = new Set([ ...selectedFiles.map(f => f.path), ..._pendingFiles.map(f => f.path) ]); const newFiles = []; for (const file of files) { let filePath = ''; try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } if (!filePath) continue; // Detect folders: directories report size 0 and empty type in Electron drag-and-drop if (file.type === '' && file.size === 0) { try { const folderFiles = await window.api.resolveFolderFiles(filePath); if (folderFiles && folderFiles.length > 0) { for (const fp of folderFiles) { if (!existingPaths.has(fp)) { const name = fp.split('\\').pop().split('/').pop(); newFiles.push({ path: fp, name, size: null }); existingPaths.add(fp); } } continue; } } catch {} } // Regular file const fileName = file.name || ''; if (!existingPaths.has(filePath)) { newFiles.push({ path: filePath, name: fileName, size: file.size }); existingPaths.add(filePath); } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } finally { _addingDropped = false; } } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; addPathsToQueue(paths); } async function pickFolder() { const paths = await window.api.selectFolder(); if (!paths) return; addPathsToQueue(paths); } function addPathsToQueue(paths) { const newFiles = []; for (const p of paths) { if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); newFiles.push({ path: p, name, size: null }); } } if (newFiles.length > 0) { _pendingFiles.push(...newFiles); openHosterModal(); } } function updateUploadView() { const dropZone = document.getElementById('dropZone'); const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); if (selectedFiles.length === 0 && queueJobs.length === 0) { dropZone.style.display = 'flex'; queueShell.style.display = 'none'; queueActions.style.display = 'none'; } else { dropZone.style.display = 'none'; queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; if (!uploading && selectedFiles.length > 0) { buildQueuePreview(); } } updateQueueActionButtons(); } function updateStartButton() { const btn = document.getElementById('startUploadBtn'); const hosters = getSelectedHosters(); const hasFiles = queueJobs.some(j => j.status === 'queued' || j.status === 'preview'); btn.disabled = uploading || hosters.length === 0 || !hasFiles; } function updateQueueActionButtons() { updateStartButton(); const hasSelection = selectedJobIds.size > 0; const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status)); const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)); const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued'].includes(job.status)); const hasMovableSelection = hasSelection && !uploading; const startSelectedBtn = document.getElementById('startSelectedBtn'); const reuploadBtn = document.getElementById('reuploadSelectedBtn'); const abortSelectedBtn = document.getElementById('abortSelectedBtn'); const finishStopBtn = document.getElementById('finishStopBtn'); const abortAllBtn = document.getElementById('abortAllBtn'); const moveTopBtn = document.getElementById('moveTopBtn'); const moveUpBtn = document.getElementById('moveUpBtn'); const moveDownBtn = document.getElementById('moveDownBtn'); const moveBottomBtn = document.getElementById('moveBottomBtn'); if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection || getSelectedHosters().length === 0; if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; if (finishStopBtn) finishStopBtn.disabled = !uploading; if (abortAllBtn) abortAllBtn.disabled = !uploading; if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection; if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; } // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); if (hosters.length === 0) { queueJobs = queueJobs.filter(j => j.status !== 'preview'); rebuildJobIndex(); renderQueueTable(); persistQueueStateSoon(); return; } // 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) { 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(); } // --- 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; // Throttled UI update scheduling – max one render per 200ms during uploads let _uiUpdateTimer = null; const UI_UPDATE_INTERVAL = 200; // ms function scheduleQueueRender() { if (_renderQueued) return; _renderQueued = true; requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); } function scheduleThrottledUIUpdate() { if (_uiUpdateTimer) return; _uiUpdateTimer = setTimeout(() => { _uiUpdateTimer = null; scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); }, UI_UPDATE_INTERVAL); } 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 + '%'}
`; } // In-place update of a single row's cells (avoids full innerHTML rebuild) function _updateRowInPlace(tr, job) { const statusClass = `status-${job.status}`; 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 || '') : ''; // Update row class tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; tr.dataset.link = link; const cells = tr.children; if (cells.length < 8) return false; // structure mismatch, needs full rebuild cells[1].textContent = uploadedSize; // cells[0] (filename) and cells[2] (hoster) don't change during upload const badge = cells[3].querySelector('.status-badge'); if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; } cells[4].textContent = elapsed; cells[5].textContent = remaining; cells[6].textContent = speed; const fill = cells[7].querySelector('.progress-bar-fill'); if (fill) { fill.style.width = pct + '%'; fill.className = `progress-bar-fill ${statusClass}`; } const pctSpan = cells[7].querySelector('.progress-pct'); if (pctSpan) pctSpan.textContent = job.status === 'preview' ? '' : pct + '%'; return true; } function renderQueueTable() { const tbody = document.getElementById('queueBody'); if (!tbody) return; _sortedJobsCache = sortQueueJobs(queueJobs); const totalRows = _sortedJobsCache.length; if (totalRows < 200) { // Try in-place update if row count matches (fast path) const existingRows = tbody.querySelectorAll('.queue-row'); if (existingRows.length === totalRows && totalRows > 0) { // In-place update – no DOM destruction for (let i = 0; i < totalRows; i++) { const tr = existingRows[i]; const job = _sortedJobsCache[i]; // If row identity changed (different job), fall back to full rebuild if (tr.dataset.jobId !== job.id) { tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; break; } _updateRowInPlace(tr, job); } } else { // Full rebuild needed (row count changed) tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); _lastVisibleRange = { start: -1, end: -1 }; } } else { // Virtual scrolling for large queues — force re-render _lastVisibleRange = { start: -1, end: -1 }; _renderVirtualRows(tbody); } // 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'; updateQueueActionButtons(); } function _renderVirtualRows(tbody) { const scrollContainer = document.getElementById('queueContainer'); if (!scrollContainer) return; const totalRows = _sortedJobsCache.length; const scrollTop = scrollContainer.scrollTop; // Use a minimum viewport height to avoid rendering nothing when container is being laid out const viewportHeight = Math.max(scrollContainer.clientHeight, 200); 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 = Math.max(0, (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; return jobs.slice().sort((a, b) => { let cmp = 0; if (key === 'filename') cmp = a.fileName.localeCompare(b.fileName, 'de', { sensitivity: 'base', numeric: true }); else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); else if (key === 'host') cmp = a.hoster.localeCompare(b.hoster); else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status); else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0); else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0); return cmp * factor; }); } function getStatusOrder(status) { const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 }; return order[status] ?? 4; } function getStatusText(job) { switch (job.status) { case 'preview': return 'Bereit'; case 'queued': return 'Wartet'; case 'getting-server': return 'Server...'; case 'uploading': return 'Upload'; case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; case 'done': return 'Fertig'; case 'aborted': return 'Abgebrochen'; case 'error': return 'Fehlgeschlagen'; case 'skipped': return 'Übersprungen'; default: return job.status; } } // --- Queue interactions --- function handleRowClick(e, row) { const jobId = row.dataset.jobId; if (e.ctrlKey || e.metaKey) { if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); else selectedJobIds.add(jobId); } else if (e.shiftKey && selectedJobIds.size > 0) { // Use sorted jobs cache for correct shift-click with virtual scrolling const sortedIds = _sortedJobsCache.map(j => j.id); const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id)); const curIdx = sortedIds.indexOf(jobId); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]); } } else { selectedJobIds.clear(); selectedJobIds.add(jobId); // Single click on done job -> copy link const job = queueJobs.find(j => j.id === jobId); if (job && job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } } } renderQueueTable(); } // --- Context menu --- let alwaysOnTopState = false; function handleRowContextMenu(e, row) { e.preventDefault(); const jobId = row.dataset.jobId; if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); renderQueueTable(); } showContextMenu(e.clientX, e.clientY); } function showContextMenu(x, y) { const menu = document.getElementById('contextMenu'); // Update "Always on top" text const aotItem = menu.querySelector('[data-action="always-on-top"]'); if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund'; // Update labels with selection count const n = selectedJobIds.size; const delItem = menu.querySelector('[data-action="delete-selected"]'); if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; const copyItem = menu.querySelector('[data-action="copy-links"]'); if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; }); const startItem = menu.querySelector('[data-action="start-selected"]'); if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten'; menu.style.display = 'block'; const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5); menu.style.left = menuX + 'px'; menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; // Flip submenus if they would overflow the viewport right edge menu.querySelectorAll('.ctx-submenu-items').forEach(sub => { sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth); }); } function hideContextMenu() { document.getElementById('contextMenu').style.display = 'none'; document.getElementById('recentContextMenu').style.display = 'none'; } function deleteSelectedRecentFiles() { if (selectedRecentIds.size === 0) return; sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order)); selectedRecentIds.clear(); renderRecentUploadsPanel(); } function copySelectedRecentLinks() { const links = sessionFilesData .filter(r => selectedRecentIds.has(r.order) && !r.isError) .map(r => r.link) .filter(Boolean); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Backup modal --- let _backupMode = null; // 'export' | 'import' function openBackupModal(mode) { _backupMode = mode; const modal = document.getElementById('backupPasswordModal'); const title = document.getElementById('backupModalTitle'); const confirmRow = document.getElementById('backupConfirmRow'); const status = document.getElementById('backupModalStatus'); document.getElementById('backupPassword').value = ''; document.getElementById('backupPasswordConfirm').value = ''; status.textContent = ''; if (mode === 'export') { title.textContent = 'Backup verschlüsseln'; confirmRow.style.display = ''; document.getElementById('confirmBackupBtn').textContent = 'Exportieren'; } else { title.textContent = 'Backup entschlüsseln'; confirmRow.style.display = 'none'; document.getElementById('confirmBackupBtn').textContent = 'Importieren'; } modal.style.display = 'flex'; document.getElementById('backupPassword').focus(); } function closeBackupModal() { document.getElementById('backupPasswordModal').style.display = 'none'; _backupMode = null; } async function confirmBackupAction() { const pw = document.getElementById('backupPassword').value; const status = document.getElementById('backupModalStatus'); if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; } if (_backupMode === 'export') { const pw2 = document.getElementById('backupPasswordConfirm').value; if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; } status.textContent = 'Exportiere...'; try { const result = await window.api.exportBackup(pw); if (result.canceled) { status.textContent = ''; return; } if (result.ok) { closeBackupModal(); showCopyToast('Backup exportiert'); } } catch (err) { status.textContent = err.message || 'Export fehlgeschlagen'; } } else { status.textContent = 'Importiere...'; try { const result = await window.api.importBackup(pw); if (result.canceled) { status.textContent = ''; return; } if (result.ok) { config = result.config; hosterSettings = config.hosterSettings || {}; // Refresh global settings state (always-on-top, etc.) alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); window.api.setAlwaysOnTop(alwaysOnTopState); closeBackupModal(); renderSettings(); renderAccounts(); renderHosterSummary(); renderHosterModal(); loadHistory(); showCopyToast('Backup importiert'); } } catch (err) { status.textContent = err.message || 'Import fehlgeschlagen'; } } } document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { hideContextMenu(); cancelHosterModal(); } if (e.target.closest('input, textarea, select')) return; const activeView = document.querySelector('.view.active'); // Ctrl+A if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); // If recent files panel is focused / has selection, select all recent files if (selectedRecentIds.size > 0 || document.activeElement?.closest('.recent-files-panel')) { sessionFilesData.forEach(r => selectedRecentIds.add(r.order)); renderRecentUploadsPanel(); } else if (queueJobs.length > 0) { queueJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); } } } // Delete if (e.key === 'Delete') { if (activeView && activeView.id === 'upload-view') { e.preventDefault(); if (selectedRecentIds.size > 0) { deleteSelectedRecentFiles(); } else if (selectedJobIds.size > 0 && !uploading) { queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(); } } } }); document.getElementById('contextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; const action = item.dataset.action; if (!action) return; hideContextMenu(); handleContextAction(action); }); async function handleContextAction(action) { if (action === 'start-selected') { startSelectedUpload(); } else if (action === 'copy-links') { const links = getSelectedJobLinks(); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'delete-selected') { queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } return true; }); selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } updateStatusBar(); persistQueueStateSoon(); } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); selectedFiles = []; syncSelectedFilesFromQueue(); renderQueueTable(); updateUploadView(); updateStatusBar(); persistQueueStateSoon(); } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; await window.api.setAlwaysOnTop(alwaysOnTopState); } else if (action.startsWith('shutdown-')) { const mode = action.replace('shutdown-', ''); await window.api.setShutdownAfterFinish(mode); } } function getSelectedJobLinks() { return queueJobs .filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); } // --- Upload --- async function startUpload() { if (healthCheckRunning || uploading) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; } if (queueJobs.length === 0 && selectedFiles.length > 0) buildQueuePreview(); const jobsToStart = queueJobs.filter((job) => job.status === 'preview' || job.status === 'queued'); if (jobsToStart.length === 0) return; // Auto health check — only check hosters that have jobs to start if (autoHealthCheckEnabled) { const jobHosters = new Set(jobsToStart.map(j => j.hoster)); const checkHosters = [...jobHosters].filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx'); if (checkHosters.length > 0) { healthCheckRunning = true; try { const rows = await executeHealthCheck(checkHosters, 'auto'); const errors = rows.filter(r => r.status === 'error'); if (errors.length > 0) { alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); return; } } catch (err) { alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); return; } finally { healthCheckRunning = false; } } } uploading = true; queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } async function startSelectedUpload() { if (healthCheckRunning || uploading) return; const hosters = getSelectedHosters(); if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; } const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && (job.status === 'preview' || job.status === 'queued')); if (jobsToStart.length === 0) return; // Auto health check — only check hosters that have jobs to start if (autoHealthCheckEnabled) { const jobHosters = new Set(jobsToStart.map(j => j.hoster)); const checkHosters = [...jobHosters].filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx'); if (checkHosters.length > 0) { healthCheckRunning = true; try { const rows = await executeHealthCheck(checkHosters, 'auto'); const errors = rows.filter(r => r.status === 'error'); if (errors.length > 0) { alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); return; } } catch (err) { alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); return; } finally { healthCheckRunning = false; } } } uploading = true; jobsToStart.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); updateQueueActionButtons(); renderQueueTable(); updateStatusBar(); const uploadPayload = { hosters, jobs: jobsToStart.map((job) => ({ id: job.id, file: job.file, fileName: job.fileName, hoster: job.hoster })) }; const result = await window.api.startUpload(uploadPayload); persistQueueStateSoon(); if (result && result.error) { alert(result.error); uploading = false; updateQueueActionButtons(); updateStatusBar(); } } async function cancelUpload() { await window.api.cancelUpload(); uploading = false; // Reset all non-finished jobs back to queued state for (const job of queueJobs) { if (!['done', 'error', 'skipped'].includes(job.status)) { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(); } // --- Progress handling --- function handleProgress(data) { let job = data.jobId ? _jobIndexById.get(data.jobId) : null; if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job) { 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; _jobIndexByUploadId.set(data.uploadId, job); } } if (!job) { job = { id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, uploadId: data.uploadId, file: '', fileName: data.fileName, hoster: data.hoster, status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0, speedKbs: 0, elapsed: 0, remaining: 0, error: null, result: null, attempt: 0, maxAttempts: 0, link: '' }; queueJobs.push(job); indexJob(job); } // Update job state job.status = data.status; job.bytesUploaded = data.bytesUploaded || 0; job.bytesTotal = data.bytesTotal || job.bytesTotal; job.speedKbs = data.speedKbs || 0; job.elapsed = data.elapsed || 0; job.remaining = data.remaining || 0; job.error = data.error || null; job.result = data.result || job.result; job.attempt = data.attempt || 0; job.maxAttempts = data.maxAttempts || 0; job.progress = data.progress || 0; if (data.uploadId) { job.uploadId = data.uploadId; _jobIndexByUploadId.set(data.uploadId, job); } maybeAddSessionFile(job); // Remove finished jobs from queue immediately if setting is enabled if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { removeJobFromIndex(job); selectedJobIds.delete(job.id); queueJobs = queueJobs.filter(j => j !== job); } // Status changes (done/error/etc) get immediate render; ongoing progress is throttled if (data.status === 'uploading') { scheduleThrottledUIUpdate(); } else { scheduleQueueRender(); updateQueueActionButtons(); updateStatusBar(); updateStatsPanel(); } persistQueueStateSoon(); } function handleBatchDone(summary) { uploading = false; applySummaryResults(summary); // Reset aborted jobs back to queued so they can be restarted for (const job of queueJobs) { if (job.status === 'aborted') { job.status = 'queued'; job.progress = 0; job.bytesUploaded = 0; job.speedKbs = 0; job.elapsed = 0; job.remaining = 0; job.error = null; } } syncSelectedFilesFromQueue(); updateQueueActionButtons(); renderQueueTable(); renderRecentUploadsPanel(); loadHistory(); const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; if (removeOnDone) { const doneJobs = queueJobs.filter(j => j.status === 'done'); for (const job of doneJobs) { removeJobFromIndex(job); selectedJobIds.delete(job.id); } queueJobs = queueJobs.filter(j => j.status !== 'done'); renderQueueTable(); } if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); else clearPersistedQueueStateSoon(); lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; updateStatusBar(); } function handleStats(data) { lastUploadStats = { state: data.state || 'idle', globalSpeedKbs: data.globalSpeedKbs || 0, totalBytes: data.totalBytes || 0, elapsed: data.elapsed || 0, activeJobs: data.activeJobs || 0 }; updateStatusBar(); updateStatsPanel(); // Track run time if (data.state === 'uploading' || data.state === 'stopping') { if (!statsStartTime) { statsStartTime = Date.now(); statsRunTimer = setInterval(() => { const el = document.getElementById('statRunTime'); if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000)); }, 1000); } } else if (data.state === 'idle' && statsRunTimer) { clearInterval(statsRunTimer); statsRunTimer = null; } } // --- Retry --- async function retrySelectedJobs() { const retryJobs = []; queueJobs.forEach(j => { if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { j.status = 'preview'; j.error = null; j.result = null; j.bytesUploaded = 0; j.speedKbs = 0; j.elapsed = 0; j.remaining = 0; j.progress = 0; j.uploadId = null; retryJobs.push(j); if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) { selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); } } }); if (retryJobs.length === 0) return; // Select the retry jobs and start them immediately selectedJobIds.clear(); retryJobs.forEach(j => selectedJobIds.add(j.id)); renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(); await startSelectedUpload(); } async function abortSelectedJobs() { const activeJobIds = []; queueJobs.forEach((job) => { if (!selectedJobIds.has(job.id)) return; if (['preview', 'queued'].includes(job.status)) { job.status = 'aborted'; job.error = 'Abgebrochen'; job.progress = 0; job.uploadId = null; } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { activeJobIds.push(job.id); } }); if (activeJobIds.length > 0) { await window.api.cancelSelectedJobs(activeJobIds); } selectedJobIds.clear(); syncSelectedFilesFromQueue(); renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); persistQueueStateSoon(true); } async function finishUploadsInProgress() { if (!uploading) return; await window.api.finishAfterActive(); lastUploadStats.state = 'stopping'; updateStatusBar(); } async function abortAllUploads() { await cancelUpload(); } function moveSelectedJobs(direction) { if (uploading || selectedJobIds.size === 0) return; const jobs = queueJobs.slice(); if (direction === 'top') { queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id))); } else if (direction === 'bottom') { queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id))); } else if (direction === 'up') { for (let i = 1; i < jobs.length; i++) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) { [jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]]; } } queueJobs = jobs; } else if (direction === 'down') { for (let i = jobs.length - 2; i >= 0; i--) { if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) { [jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]]; } } queueJobs = jobs; } rebuildJobIndex(); renderQueueTable(); updateStatusBar(); persistQueueStateSoon(true); } function syncSelectedFilesFromQueue() { const fileMap = new Map(); queueJobs .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) .forEach((job) => { if (!job.file || fileMap.has(job.file)) return; fileMap.set(job.file, { path: job.file, name: job.fileName, size: job.bytesTotal || 0 }); }); selectedFiles = Array.from(fileMap.values()); } function maybeAddSessionFile(job) { if (!job) return; const dt = formatDateTime(new Date()); if (job.status === 'done' && job.result) { const link = job.result.download_url || job.result.embed_url || ''; if (!link) return; if (!sessionFilesData.some((row) => row.link === link && row.filename === job.fileName && row.host === job.hoster)) { sessionFilesData.push({ date: dt.text, dateTs: dt.ts, filename: job.fileName || '', host: job.hoster || '', link, isError: false, order: sessionFilesData.length }); renderRecentUploadsPanel(); } } if (job.status === 'error') { const errorText = `[Fehler] ${job.error || ''}`; if (!sessionFilesData.some((row) => row.isError && row.filename === job.fileName && row.host === job.hoster && row.link === errorText)) { sessionFilesData.push({ date: dt.text, dateTs: dt.ts, filename: job.fileName || '', host: job.hoster || '', link: errorText, isError: true, order: sessionFilesData.length }); renderRecentUploadsPanel(); } } } function applySummaryResults(summary) { const files = Array.isArray(summary?.files) ? summary.files : []; for (const file of files) { for (const result of file.results || []) { const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster); if (!job) continue; if (result.status === 'done') { job.status = 'done'; job.result = { download_url: result.download_url || null, embed_url: result.embed_url || null, file_code: result.file_code || null }; job.error = null; job.progress = 1; job.bytesUploaded = job.bytesTotal || file.size || 0; } else if (result.status === 'aborted') { job.status = 'aborted'; job.error = result.error || 'Abgebrochen'; } else if (result.status === 'error') { job.status = 'error'; job.error = result.error || 'Fehlgeschlagen'; } maybeAddSessionFile(job); } } } // Single-pass queue stats computation (shared by status bar + stats panel) function _computeQueueStats() { let remaining = 0, inProgress = 0, done = 0, errors = 0; let bytesRemaining = 0, totalSize = 0, remainingSize = 0; const total = queueJobs.length; for (let i = 0; i < total; i++) { const job = queueJobs[i]; const s = job.status; const bt = job.bytesTotal || 0; const bu = job.bytesUploaded || 0; totalSize += bt; if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { inProgress++; remaining++; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'preview' || s === 'queued') { remaining++; bytesRemaining += Math.max(0, bt - bu); remainingSize += Math.max(0, bt - bu); } else if (s === 'done') { done++; } else if (s === 'error') { errors++; } else if (s !== 'skipped') { remainingSize += Math.max(0, bt - bu); } } return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize }; } function updateStatusBar() { const stats = _computeQueueStats(); const etaSeconds = lastUploadStats.globalSpeedKbs > 0 ? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) : 0; const stateText = lastUploadStats.state === 'uploading' ? 'Upload läuft...' : lastUploadStats.state === 'stopping' ? 'Stoppt nach aktiven Uploads...' : uploading ? 'Upload vorbereitet...' : 'Bereit'; document.getElementById('sbState').textContent = stateText; document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); const uploadedSize = Math.max(0, stats.totalSize - stats.remainingSize); document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(stats.remainingSize)}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbConnections').textContent = `Aktive Verbindungen ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`; document.getElementById('sbRemainingCount').textContent = `Remaining ${stats.remaining}`; document.getElementById('sbInProgressCount').textContent = `In Progress ${stats.inProgress}`; document.getElementById('sbErrorCount').textContent = `Error ${stats.errors}`; } // --- Health Check --- function setHealthCheckStatus(text) { // Minimal inline status } function renderHealthCheckResults(results) { const container = document.getElementById('healthCheckResults'); if (!container) return; if (!results || results.length === 0) { container.innerHTML = ''; return; } container.innerHTML = results.map(item => { const status = item.status || 'skipped'; return `
${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')} [${status.toUpperCase()}] ${escapeHtml(item.message || '')}
`; }).join(''); } async function executeHealthCheck(hosters, mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; rows.forEach((row) => { if (!row || !row.hoster) return; accountStatuses[row.hoster] = { status: row.status || 'unchecked', message: row.message || '' }; }); renderHealthCheckResults(rows); renderAccounts(); renderHosterModal(); return rows; } async function runHealthCheck(mode = 'manual', requestedHosters = null) { if (healthCheckRunning || (uploading && mode === 'manual')) return []; const hosters = Array.isArray(requestedHosters) && requestedHosters.length > 0 ? requestedHosters : HOSTERS.filter((name) => hosterHasCredentials(name, config.hosters[name] || {})); if (hosters.length === 0) { if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); return []; } healthCheckRunning = true; hosters.forEach((hoster) => { accountStatuses[hoster] = { status: 'checking', message: '' }; }); renderAccounts(); try { return await executeHealthCheck(hosters, mode); } catch (err) { renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); return []; } finally { healthCheckRunning = false; renderAccounts(); } } // --- Settings --- function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; const globalSettings = config.globalSettings || {}; const configuredAccounts = getAccountsWithCreds(); const generalPanel = document.createElement('div'); generalPanel.className = 'hoster-settings-panel'; generalPanel.innerHTML = `
Allgemein System
Uploads
0 = nur pro Hoster
0 = unbegrenzt
Verhalten
Updates
Log
`; container.appendChild(generalPanel); // Toggle general panel generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = generalPanel.querySelector('.hoster-panel-body'); const arrow = generalPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // --- Folder Monitor Panel --- const fm = globalSettings.folderMonitor || {}; const folderMonitorPanel = document.createElement('div'); folderMonitorPanel.className = 'hoster-settings-panel'; folderMonitorPanel.innerHTML = `
Ordnerüberwachung ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}
`; container.appendChild(folderMonitorPanel); // Toggle folder monitor panel folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = folderMonitorPanel.querySelector('.hoster-panel-body'); const arrow = folderMonitorPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); // Update badge immediately on checkbox/path change const updateFmBadge = () => { const b = document.getElementById('folderMonitorStatusBadge'); if (!b) return; const enabled = document.getElementById('fmEnabledInput')?.checked; const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim(); if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; } else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; } }; document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge); document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge); document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => { const folder = await window.api.folderMonitorSelectFolder(); if (folder) { document.getElementById('fmFolderPathInput').value = folder; updateFmBadge(); scheduleSettingsSave(); } }); // --- Backup Panel --- const backupPanel = document.createElement('div'); backupPanel.className = 'hoster-settings-panel'; backupPanel.innerHTML = `
Backup System
`; container.appendChild(backupPanel); backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = backupPanel.querySelector('.hoster-panel-body'); const arrow = backupPanel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export')); document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import')); // --- Separator before hoster panels --- const separator = document.createElement('div'); separator.style.cssText = 'height:16px'; container.appendChild(separator); if (configuredAccounts.length === 0) { const empty = document.createElement('div'); empty.className = 'settings-empty'; empty.innerHTML = '

Noch keine Account-Einstellungen vorhanden.

Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.'; container.appendChild(empty); } for (const { name, hoster } of configuredAccounts) { const hs = hosterSettings[name] || {}; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; const panel = document.createElement('div'); panel.className = 'hoster-settings-panel'; panel.innerHTML = `
${escapeHtml(getAccountDisplayName(name, hoster))} Aktiv
`; container.appendChild(panel); // Toggle panel panel.querySelector('.hoster-panel-header').addEventListener('click', () => { const body = panel.querySelector('.hoster-panel-body'); const arrow = panel.querySelector('.panel-arrow'); const isOpen = body.style.display !== 'none'; body.style.display = isOpen ? 'none' : 'block'; arrow.innerHTML = isOpen ? '▶' : '▼'; }); } document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; btn.textContent = 'Prüfe...'; try { const result = await window.api.checkForUpdate(); if (result && result.available) { showUpdateBanner(result); btn.textContent = 'Update gefunden!'; } else { btn.textContent = 'Kein Update verfügbar'; } } catch { btn.textContent = 'Fehler beim Prüfen'; } setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000); }); container.querySelectorAll('.settings-autosave').forEach((input) => { const eventName = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(eventName, scheduleSettingsSave); }); } async function chooseLogFilePath() { const folders = await window.api.selectFolder(); if (!folders || !folders[0]) return; const normalized = folders[0].replace(/[\\\/]+$/, ''); document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; scheduleSettingsSave(); } function scheduleSettingsSave() { const feedback = document.getElementById('saveFeedback'); if (feedback) feedback.textContent = 'Speichert...'; clearTimeout(settingsSaveTimer); settingsSaveTimer = setTimeout(() => { saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; }); }, 350); } async function saveSettings(options = {}) { const { feedbackText = 'Gespeichert!' } = options; const newHosterSettings = { ...(config.hosterSettings || {}) }; const globalSettings = { ...(config.globalSettings || {}), logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), sessionLog: !!document.getElementById('sessionLogInput')?.checked, resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked, globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), folderMonitor: { enabled: !!document.getElementById('fmEnabledInput')?.checked, folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), recursive: !!document.getElementById('fmRecursiveInput')?.checked, filterMode: document.getElementById('fmFilterModeInput')?.value || 'include', extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(), skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked, delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3), autoStart: !!document.getElementById('fmAutoStartInput')?.checked, hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster) } }; // Always on top setting const aotCheckbox = document.getElementById('alwaysOnTopInput'); if (aotCheckbox) { const newAot = !!aotCheckbox.checked; if (newAot !== alwaysOnTopState) { alwaysOnTopState = newAot; await window.api.setAlwaysOnTop(alwaysOnTopState); } } for (const name of HOSTERS) { const hs = { ...(hosterSettings[name] || {}) }; document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { const field = input.dataset.hs; if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024)); else hs[field] = parseInt(input.value, 10) || 0; }); newHosterSettings[name] = hs; } await window.api.saveHosterSettings(newHosterSettings); await window.api.saveGlobalSettings(globalSettings); config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; clearTimeout(settingsSaveTimer); // Start/stop folder monitor based on settings const fmSettings = globalSettings.folderMonitor; const badge = document.getElementById('folderMonitorStatusBadge'); if (fmSettings && fmSettings.enabled && fmSettings.folderPath) { try { await window.api.folderMonitorStart(fmSettings); if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; } } catch { if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; } } } else { await window.api.folderMonitorStop(); if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; } } const feedback = document.getElementById('saveFeedback'); feedback.textContent = feedbackText; setTimeout(() => { if (feedback.textContent === feedbackText) { feedback.textContent = 'Änderungen werden automatisch gespeichert.'; } }, 1800); } // --- Accounts --- function getAccountsWithCreds() { return HOSTERS .map(name => ({ name, hoster: config.hosters[name] || {} })) .filter(item => hosterHasCredentials(item.name, item.hoster)); } function getHostersWithoutCreds() { return HOSTERS.filter(name => !hosterHasCredentials(name, config.hosters[name] || {})); } function getCredentialLabel(name, hoster) { if (name === 'vidmoly.me') return `Login: ${hoster.username || 'nicht gesetzt'}`; if (name === 'voe.sx' || name === 'doodstream.com') { const parts = []; if (hoster.username && hoster.password) parts.push(`Login: ${hoster.username}`); if (hoster.apiKey) parts.push(`API: ${maskCredential(hoster.apiKey)}`); return parts.join(' • ') || 'Keine Zugangsdaten'; } return `API: ${maskCredential(hoster.apiKey) || 'nicht gesetzt'}`; } function renderAccounts() { const container = document.getElementById('accountsList'); if (!container) return; ensureAccountStatusEntries(); const accounts = getAccountsWithCreds(); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; if (accounts.length === 0) { container.innerHTML = `

Keine Accounts vorhanden

Klicke auf "Account hinzufügen", um einen Hoster einzurichten.
`; return; } container.innerHTML = accounts.map(({ name, hoster }) => { const st = accountStatuses[name] || { status: 'unchecked', message: '' }; const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; const statusLabel = statusLabels[st.status] || 'Nicht geprüft'; const credLabel = getCredentialLabel(name, hoster); return `
${statusLabel}
`; }).join(''); // Wire up buttons container.querySelectorAll('[data-account-edit]').forEach(btn => { btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit)); }); container.querySelectorAll('[data-account-delete]').forEach(btn => { btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete)); }); container.querySelectorAll('[data-account-check]').forEach(btn => { btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck)); }); } async function checkSingleAccount(hosterName) { if (!hosterName || healthCheckRunning) return; healthCheckRunning = true; accountStatuses[hosterName] = { status: 'checking', message: '' }; renderAccounts(); try { const rows = await executeHealthCheck([hosterName], 'manual'); const row = rows.find(r => r.hoster === hosterName); if (row) accountStatuses[hosterName] = { status: row.status || 'error', message: row.message || '' }; } catch (err) { accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; } finally { healthCheckRunning = false; } renderAccounts(); } function getCredsFieldsHtml(name, hoster) { hoster = hoster || {}; if (name === 'vidmoly.me') { return `
`; } if (name === 'voe.sx' || name === 'doodstream.com') { return `

Login wird bevorzugt. API-Key nur als Fallback.

`; } // Default: API key only return `
`; } function openAccountModal(editHoster) { editingAccountHoster = editHoster || null; const modal = document.getElementById('accountModal'); const title = document.getElementById('accountModalTitle'); const subtitle = document.getElementById('accountModalSubtitle'); const hosterRow = document.getElementById('accountHosterRow'); const hosterSelect = document.getElementById('accountHosterSelect'); const credsContainer = document.getElementById('accountCredsFields'); const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); statusEl.textContent = ''; statusEl.className = 'account-modal-status'; if (editingAccountHoster) { // Edit mode title.textContent = 'Account bearbeiten'; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(editingAccountHoster, config.hosters[editingAccountHoster] || {})} bearbeiten.`; hosterRow.style.display = 'none'; saveBtn.textContent = 'Speichern & prüfen'; const hoster = config.hosters[editingAccountHoster] || {}; credsContainer.innerHTML = getCredsFieldsHtml(editingAccountHoster, hoster); } else { // Add mode title.textContent = 'Account hinzufügen'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; hosterRow.style.display = 'flex'; saveBtn.textContent = 'Anlegen & prüfen'; const available = getHostersWithoutCreds(); if (available.length === 0) { hosterSelect.innerHTML = ''; credsContainer.innerHTML = ''; } else { hosterSelect.innerHTML = available.map(name => ``).join(''); credsContainer.innerHTML = getCredsFieldsHtml(available[0], {}); } } // Toggle visibility buttons credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); modal.style.display = 'flex'; } function closeAccountModal() { document.getElementById('accountModal').style.display = 'none'; editingAccountHoster = null; } function openDeleteAccountModal(hosterName) { const modal = document.getElementById('deleteAccountModal'); const msg = document.getElementById('deleteAccountMessage'); msg.textContent = `Account für "${getHosterLabel(hosterName)}" wirklich löschen? Alle Zugangsdaten werden entfernt.`; modal.dataset.hoster = hosterName; modal.style.display = 'flex'; } function closeDeleteModal() { document.getElementById('deleteAccountModal').style.display = 'none'; } async function deleteAccount(hosterName) { const hosters = { ...config.hosters }; // Reset credentials to defaults if (hosterName === 'vidmoly.me') { hosters[hosterName] = { enabled: false, authType: 'login', username: '', password: '' }; } else if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { hosters[hosterName] = { enabled: false, username: '', password: '', apiKey: '' }; } else { hosters[hosterName] = { enabled: false, apiKey: '' }; } delete accountStatuses[hosterName]; await window.api.saveConfig({ hosters }); config = await window.api.getConfig(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAccountsWithCreds().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); closeDeleteModal(); } function readAccountCredsFromModal(hosterName) { if (hosterName === 'vidmoly.me') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); return { enabled: !!(username && password), authType: 'login', username, password }; } if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); return { enabled: !!(username && password) || !!apiKey, username, password, apiKey }; } const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); return { enabled: !!apiKey, apiKey }; } async function saveAccount() { const hosterName = editingAccountHoster || document.getElementById('accountHosterSelect')?.value; if (!hosterName) return; const creds = readAccountCredsFromModal(hosterName); if (!creds.enabled) { const statusEl = document.getElementById('accountModalStatus'); statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.className = 'account-modal-status error'; return; } // Save credentials const hosters = { ...config.hosters }; hosters[hosterName] = creds; await window.api.saveConfig({ hosters }); config = await window.api.getConfig(); // Show checking status const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); statusEl.textContent = 'Prüfe Login...'; statusEl.className = 'account-modal-status checking'; saveBtn.disabled = true; accountStatuses[hosterName] = { status: 'checking', message: '' }; syncSelectedUploadHosters(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); // Run health check try { const rows = await executeHealthCheck([hosterName], 'auto'); const row = rows.find(r => r.hoster === hosterName); if (row && (row.status === 'ok' || row.status === 'warn')) { accountStatuses[hosterName] = { status: row.status || 'ok', message: row.message || '' }; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; statusEl.className = 'account-modal-status ok'; setTimeout(() => closeAccountModal(), 1200); } else { const msg = (row && row.message) || 'Login fehlgeschlagen'; accountStatuses[hosterName] = { status: 'error', message: msg }; statusEl.textContent = msg; statusEl.className = 'account-modal-status error'; } } catch (err) { accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; statusEl.className = 'account-modal-status error'; } finally { saveBtn.disabled = false; ensureAccountStatusEntries(); renderAccounts(); renderHosterSummary(); renderHosterModal(); renderSettings(); } } // --- History --- async function loadHistory() { const history = await window.api.getHistory(); const container = document.getElementById('historyContainer'); if (!history || history.length === 0) { historyRowsData = []; container.innerHTML = '

Noch keine Uploads.

'; return; } historySortState = { key: 'date', direction: 'desc' }; historyRowsData = []; let order = 0; for (const batch of history) { const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { if (result.status === 'aborted' || result.status === 'error') continue; historyRowsData.push({ date: dt.text, dateTs: dt.ts, filename: file.name || '', host: result.hoster || '', link: result.download_url || result.embed_url || '', isError: false, order: order++ }); } } } renderHistoryTable(container); } function sortRecentFiles(data) { const sorted = data.slice(); const { key, direction } = recentSortState; const dir = direction === 'asc' ? 1 : -1; sorted.sort((a, b) => { if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); if (key === 'filename') return dir * a.filename.localeCompare(b.filename, 'de', { sensitivity: 'base' }); if (key === 'host') return dir * a.host.localeCompare(b.host, 'de', { sensitivity: 'base' }); if (key === 'link') return dir * a.link.localeCompare(b.link, 'de', { sensitivity: 'base' }); return 0; }); return sorted; } function updateRecentSortHeaders() { const head = document.getElementById('recentFilesHead'); if (!head) return; head.querySelectorAll('th[data-recent-sort]').forEach(th => { const key = th.dataset.recentSort; const active = recentSortState.key === key; const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕'; th.classList.toggle('active', active); const indicator = th.querySelector('.sort-indicator'); if (indicator) indicator.textContent = arrow; }); } let _recentListenersBound = false; function renderRecentUploadsPanel() { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; if (!sessionFilesData.length) { tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; return; } const rows = sortRecentFiles(sessionFilesData); tbody.innerHTML = rows.map(row => ` ${escapeHtml(row.date)} ${escapeHtml(row.filename)} ${escapeHtml(row.host)} ${escapeHtml(row.link)} `).join(''); // Event delegation – bind once, not per-row if (!_recentListenersBound) { _recentListenersBound = true; tbody.addEventListener('click', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; const id = parseInt(tr.dataset.order, 10); if (e.ctrlKey || e.metaKey) { if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); else selectedRecentIds.add(id); } else if (e.shiftKey && selectedRecentIds.size > 0) { const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order); const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); const curIdx = sortedOrders.indexOf(id); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); const to = Math.max(lastIdx, curIdx); for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]); } } else { selectedRecentIds.clear(); selectedRecentIds.add(id); } renderRecentUploadsPanel(); }); tbody.addEventListener('dblclick', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr || tr.classList.contains('error')) return; const link = tr.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); } updateRecentSortHeaders(); } function renderHistoryTable(container) { if (!container || !historyRowsData.length) { if (container) container.innerHTML = '

Noch keine Uploads.

'; return; } const rows = sortHistoryRows(historyRowsData); const headerCell = (key, label) => { const active = historySortState.key === key; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; return `${label}${dir}`; }; let html = ` ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} `; rows.forEach(row => { html += ``; }); html += '
${escapeHtml(row.date)} ${escapeHtml(row.filename)} ${escapeHtml(row.host)}
'; container.innerHTML = html; container.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { const key = th.dataset.historySort; if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; } renderHistoryTable(container); }); }); container.querySelectorAll('.history-row').forEach(row => { row.addEventListener('click', () => { if (row.classList.contains('error')) return; const link = row.dataset.link; if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); }); } function sortHistoryRows(rows) { const { key, direction } = historySortState; const factor = direction === 'asc' ? 1 : -1; return rows.slice().sort((a, b) => { let cmp = key === 'date' ? a.dateTs - b.dateTs : String(a[key] || '').localeCompare(String(b[key] || ''), 'de', { sensitivity: 'base', numeric: true }); return (cmp || a.order - b.order) * factor; }); } // Flush pending queue state on window close window.addEventListener('beforeunload', () => { if (queuePersistTimer) { clearTimeout(queuePersistTimer); queuePersistTimer = null; // Synchronous-ish: fire and forget since window is closing persistQueueStateNow().catch(() => {}); } }); // --- Setup Listeners --- function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); document.getElementById('addFolderBtn').addEventListener('click', pickFolder); document.getElementById('startUploadBtn').addEventListener('click', startUpload); document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); // Recent files sort headers document.getElementById('recentFilesHead').addEventListener('click', (e) => { const th = e.target.closest('th[data-recent-sort]'); if (!th) return; const key = th.dataset.recentSort; if (recentSortState.key === key) { recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; } else { recentSortState.key = key; recentSortState.direction = key === 'date' ? 'desc' : 'asc'; } renderRecentUploadsPanel(); }); // Recent files context menu document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; e.preventDefault(); e.stopPropagation(); const id = parseInt(tr.dataset.order, 10); if (!selectedRecentIds.has(id)) { selectedRecentIds.clear(); selectedRecentIds.add(id); renderRecentUploadsPanel(); } const menu = document.getElementById('recentContextMenu'); menu.style.display = 'block'; menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px'; }); document.getElementById('recentContextMenu').addEventListener('click', (e) => { const item = e.target.closest('.ctx-item'); if (!item) return; hideContextMenu(); const action = item.dataset.action; if (action === 'recent-copy-links') copySelectedRecentLinks(); else if (action === 'recent-delete') deleteSelectedRecentFiles(); }); document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads); document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top')); document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up')); document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down')); document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom')); document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual')); document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); document.getElementById('retryFailedBtn').addEventListener('click', () => { queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); retrySelectedJobs(); }); document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection); document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal); document.getElementById('selectAllHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = true; input.closest('.hoster-option')?.classList.add('selected'); }); }); document.getElementById('clearHostersBtn').addEventListener('click', () => { document.querySelectorAll('input[data-hoster-modal]').forEach(input => { input.checked = false; input.closest('.hoster-option')?.classList.remove('selected'); }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); // --- Backup export / import (modal listeners stay here, button listeners in renderSettings) --- document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal); document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal); document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction); document.getElementById('backupPasswordModal').addEventListener('click', (e) => { if (e.target.id === 'backupPasswordModal') closeBackupModal(); }); document.getElementById('backupPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmBackupAction(); }); document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmBackupAction(); }); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; await window.api.clearHistory(); loadHistory(); }); // Auto health check toggle const autoToggle = document.getElementById('autoHealthCheckToggle'); if (autoToggle) { autoToggle.checked = autoHealthCheckEnabled; autoToggle.addEventListener('change', (e) => { autoHealthCheckEnabled = !!e.target.checked; try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} }); } // 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', () => { const key = th.dataset.sort; if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc'; else { queueSortState.key = key; queueSortState.direction = 'asc'; } renderQueueTable(); }); }); // Shutdown cancel document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { await window.api.cancelShutdown(); if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } document.getElementById('shutdownOverlay').style.display = 'none'; }); // Click on empty area in queue → deselect all document.getElementById('upload-view').addEventListener('click', (e) => { if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); } } }); // Right-click on upload view background document.getElementById('upload-view').addEventListener('contextmenu', (e) => { if (e.target.closest('.queue-row')) return; // handled per row if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue e.preventDefault(); showContextMenu(e.clientX, e.clientY); }); document.getElementById('hosterModal').addEventListener('click', (e) => { if (e.target.id === 'hosterModal') cancelHosterModal(); }); // Account management document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null)); document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal); document.getElementById('saveAccountBtn').addEventListener('click', saveAccount); document.getElementById('accountModal').addEventListener('click', (e) => { if (e.target.id === 'accountModal') closeAccountModal(); }); // Account hoster select change → update credential fields document.getElementById('accountHosterSelect').addEventListener('change', (e) => { const credsContainer = document.getElementById('accountCredsFields'); credsContainer.innerHTML = getCredsFieldsHtml(e.target.value, {}); credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { btn.addEventListener('click', () => { const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); }); document.getElementById('accountModalStatus').textContent = ''; document.getElementById('accountModalStatus').className = 'account-modal-status'; }); // Delete account modal document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal); document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal); document.getElementById('confirmDeleteBtn').addEventListener('click', () => { const modal = document.getElementById('deleteAccountModal'); const hoster = modal.dataset.hoster; if (hoster) deleteAccount(hoster); }); document.getElementById('deleteAccountModal').addEventListener('click', (e) => { if (e.target.id === 'deleteAccountModal') closeDeleteModal(); }); } // --- Update UI --- function showUpdateBanner(info) { const banner = document.getElementById('updateBanner'); const msg = document.getElementById('updateMessage'); if (!banner || !msg) return; msg.textContent = `Update v${info.remoteVersion} verfügbar`; banner.style.display = 'flex'; document.getElementById('installUpdateBtn').onclick = async () => { msg.textContent = 'Update wird heruntergeladen...'; document.getElementById('installUpdateBtn').disabled = true; await window.api.installUpdate(); }; document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; }; } function handleUpdateProgress(data) { const msg = document.getElementById('updateMessage'); if (!msg) return; if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'error') { msg.textContent = `Update fehlgeschlagen: ${data.error}`; const btn = document.getElementById('installUpdateBtn'); if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } } } // --- Shutdown --- let shutdownCountdownInterval = null; function handleShutdownCountdown(data) { const overlay = document.getElementById('shutdownOverlay'); const msgEl = document.getElementById('shutdownMessage'); const secEl = document.getElementById('shutdownSeconds'); overlay.style.display = 'flex'; const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' }; let remaining = data.seconds || 60; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = setInterval(() => { remaining--; secEl.textContent = remaining; msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; if (remaining <= 0) { clearInterval(shutdownCountdownInterval); } }, 1000); } // --- Link operations --- function copyAllLinks() { const links = queueJobs .filter(j => j.status === 'done' && j.result) .map(j => j.result.download_url || j.result.embed_url || '') .filter(Boolean); if (links.length > 0) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } // --- Utilities --- function formatSize(bytes) { if (!bytes || bytes <= 0) return '0 B'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatSpeed(kbs) { if (!kbs || kbs <= 0) return '0 kB/s'; if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s'; return Math.round(kbs) + ' kB/s'; } function formatTime(seconds) { if (!seconds || seconds <= 0) return '00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`; return `${pad(m)}:${pad(s)}`; } function pad(n) { return String(Math.floor(n)).padStart(2, '0'); } function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); const safeDate = Number.isNaN(date.getTime()) ? new Date() : date; return { ts: safeDate.getTime(), text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }; } function loadAutoCheckPreference() { try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; } catch { return true; } } function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escapeAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); } function showCopyToast(msg) { const toast = document.getElementById('copyToast'); toast.textContent = msg; toast.classList.add('show'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('show'), 1500); } // --- Resize handle for recent-files panel --- { const resizer = document.getElementById('recentFilesResizer'); const panel = document.getElementById('recentFilesPanel'); if (resizer && panel) { let startY = 0; let startH = 0; resizer.addEventListener('mousedown', (e) => { e.preventDefault(); startY = e.clientY; startH = panel.getBoundingClientRect().height; resizer.classList.add('dragging'); document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; const onMove = (e2) => { const delta = startY - e2.clientY; const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta)); panel.style.flex = `0 0 ${newH}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); resizer.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } } // --- Recent panel tabs --- document.querySelectorAll('.recent-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active')); tab.classList.add('active'); const panel = document.getElementById(tab.dataset.panel); if (panel) panel.classList.add('active'); const hint = document.getElementById('recentFilesHint'); if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'; }); }); // --- Stats panel update --- let statsStartTime = 0; let statsRunTimer = null; function formatBytes(bytes) { if (bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; } function formatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function updateStatsPanel() { const stats = _computeQueueStats(); const remaining = stats.total - stats.done - stats.errors; const el = (id) => document.getElementById(id); if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total; if (el('statQueueDone')) el('statQueueDone').textContent = stats.done; if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining; if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress; if (el('statQueueError')) el('statQueueError').textContent = stats.errors; if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize); if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize); const speed = lastUploadStats.globalSpeedKbs || 0; if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s'; if (el('statEta')) { if (speed > 0 && stats.remainingSize > 0) { el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024))); } else { el('statEta').textContent = '--:--'; } } if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0); } // --- Start --- init();