Multi-Hoster-Upload-2/src/app.js
Claude 615161d747 Full port: v1 renderer shim + folder monitor + remote server + updater + upload log fallback
Major additions:

Frontend — v1 renderer/app.js and renderer/index.html copied 1:1. A
new tauri-shim.js reconstructs window.api with all 54 methods v1 used,
each mapped to a matching Rust #[tauri::command] so the existing
renderer works unchanged. Drag-drop paths are bridged via the Tauri
tauri://drag-drop event.

Backend modules:
  - folder_monitor.rs: notify-debouncer-full based recursive/
    non-recursive watch, include/exclude extension filter, skip-dupes,
    initial baseline scan, emits 'folder-monitor-new-files' to frontend.
    Auto-restarts on launch if persisted settings have enabled=true.
  - remote_server.rs: axum HTTP server with bearer-token auth exposing
    /api/status, /api/control/cancel, /api/control/finish-after.
    Auto-restarts on launch if enabled + token set.
  - updater.rs: Gitea releases polling + semver compare, returns
    download URL for current platform. install_update opens the URL
    externally (true in-app update needs signing cert later).
  - upload_log.rs: full fallback ladder (primary → Desktop → AppData),
    daily-log suffix handling, auto-persists working fallback path into
    globalSettings.logFilePath so next session writes there directly,
    emits 'upload-log-fallback' to the renderer once per session.

Commands added (all wired into tauri::generate_handler!):
  get_hoster_settings, save_hoster_settings, get_global_settings,
  save_global_settings, set_always_on_top, get_always_on_top,
  set_shutdown_after_finish, get_shutdown_after_finish, cancel_shutdown,
  resolve_folder_files, copy_to_clipboard (Windows clipboard via
  PowerShell pipe), start_upload, add_jobs_to_batch,
  finish_after_active, run_health_check, export_backup, import_backup,
  import_backup_saved (legacy-password path), read_own_upload_log,
  import_upload_log, save_text_file, open_log_folder,
  read_rotation_log, get_version, check_for_update, install_update,
  folder_monitor_start/stop/status, remote_get_settings,
  remote_save_settings, remote_generate_token, remote_status,
  show_drop_target, hide_drop_target, debug_log.

Release build: exe 7.5 MB, NSIS 2.7 MB, MSI 3.7 MB.
2026-04-20 17:41:11 +02:00

4057 lines
158 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
// Dropdown options for "Add Account" modal: value -> label
const HOSTER_ADD_OPTIONS = [
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
];
// --- State ---
let selectedFiles = []; // { path, name, size }
let selectedUploadHosters = [];
let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {};
let uploading = false;
let healthCheckRunning = false;
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountId = null; // null = adding, string = editing account by ID
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';
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']);
function isStartableQueueStatus(status) {
return STARTABLE_QUEUE_STATUSES.has(status);
}
function isStartableQueueJob(job) {
return !!job && isStartableQueueStatus(job.status);
}
// Queue state
let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link }
const _jobIndexById = new Map(); // id -> job (O(1) lookup)
const _jobIndexByUploadId = new Map(); // uploadId -> job
const selectedJobIds = new Set();
let _sessionTotalBytes = 0; // Total bytes ever added to queue this session
let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs)
const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes
const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
const 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 = [];
const recentSortState = { key: 'date', direction: 'desc' };
const selectedRecentIds = new Set();
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
let _sessionDoneCount = 0;
let _sessionErrorCount = 0;
// O(1) dedup for maybeAddSessionFile (was O(n) sessionFilesData.some).
// Huge with thousands of rows × thousands of incoming results.
const _sessionFileKeys = new Set();
// --- Init ---
async function init() {
config = await window.api.getConfig();
hosterSettings = config.hosterSettings || {};
autoHealthCheckEnabled = loadAutoCheckPreference();
ensureAccountStatusEntries();
syncSelectedUploadHosters();
restoreQueueStateFromConfig();
await _autoDeduplicateFromLog();
renderHosterSummary();
renderHosterModal();
renderSettings();
renderAccounts();
setupListeners();
setupDragDrop();
restoreQueueColumnWidths();
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 — debug log only on state transitions; the 'uploading'
// tick fires 4×/sec per active job and an IPC roundtrip per event would
// backlog the renderer↔main channel with hundreds of messages/sec.
window.api.onUploadProgress((data) => {
if (data.status !== 'uploading') {
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) => {
// Stats fire every second per upload session — skip while uploading.
if (data.state !== 'uploading') {
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
}
handleStats(data);
});
window.api.onShutdownCountdown(handleShutdownCountdown);
window.api.onUploadLogFallback((data) => {
const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)';
showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000);
});
window.api.onLogPathAutoUpdated((data) => {
if (!data || !data.logFilePath) return;
// Keep the in-memory config and the visible Settings input in sync so
// the user sees the path that's actually being written to, and the
// next save from the UI doesn't revert it.
if (config && config.globalSettings) config.globalSettings.logFilePath = data.logFilePath;
const input = document.getElementById('logFilePathInput');
if (input) input.value = data.logFilePath;
showCopyToast(`Log-Pfad automatisch auf funktionierenden Ordner gesetzt`, 5000);
});
window.api.onAccountRotationLog((entry) => {
// Surface only the user-visible rotation events as toasts; full detail
// goes to account-rotation.log. Keep it quiet otherwise.
if (!entry || !entry.event) return;
const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : '';
if (entry.event === 'rotate') {
showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`);
} else if (entry.event === 'rotation-end') {
showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`);
} else if (entry.event === 'final-error') {
showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`);
}
});
// 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 existing = new Set();
for (const f of selectedFiles) existing.add(f.path);
for (const f of _pendingFiles) existing.add(f.path);
const newFiles = [];
for (const p of files) {
if (existing.has(p)) continue;
existing.add(p);
const name = p.split('\\').pop().split('/').pop();
newFiles.push({ path: p, name, size: null });
}
if (newFiles.length > 0) {
const newPaths = new Set(newFiles.map(f => f.path));
selectedFiles.push(...newFiles);
buildQueuePreview();
updateUploadView();
if (fm.autoStart && !uploading && !healthCheckRunning) {
startUpload();
} else if (uploading) {
// Inject new preview jobs into the running batch
const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file));
if (newJobs.length > 0) {
newJobs.forEach(j => { j.status = 'queued'; });
renderQueueTable();
window.api.addJobsToBatch({
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
persistQueueStateSoon(true);
}
}
}
} else {
// No pre-selected hosters: open modal
addPathsToQueue(files);
}
});
// Account switched notification
window.api.onAccountSwitched((data) => {
window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`);
});
// Drop target window: files dropped on the small floating window
window.api.onDropTargetFiles((paths) => {
addPathsToQueue(paths);
});
// Remote client count updates (registered once, not per renderSettings call)
window.api.onRemoteClientCount(() => {
const el = document.getElementById('remoteConnectionStatus');
if (el && el.style.color === 'rgb(16, 185, 129)') {
window.api.remoteStatus().then(status => {
if (status.running) {
el.textContent = `Aktiv auf Port ${status.port}${status.clientCount} Client(s) verbunden`;
}
}).catch(() => {});
}
});
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 ---
let _historyDirty = false;
function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history');
}
// Cache the tab/view collections once and use event delegation on the parent
// so tab switches don't trigger three querySelectorAll walks per click.
(() => {
const tabs = Array.from(document.querySelectorAll('.tab'));
const views = Array.from(document.querySelectorAll('.view'));
const tabsByView = {};
const viewsById = {};
for (const t of tabs) tabsByView[t.dataset.view] = t;
for (const v of views) viewsById[v.id] = v;
let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0];
const handle = (target) => {
const tab = target.closest('.tab');
if (!tab || tab === activeTab) return;
if (activeTab) {
activeTab.classList.remove('active');
const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active');
}
tab.classList.add('active');
const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active');
activeTab = tab;
if (tab.dataset.view === 'history') {
_historyDirty = false;
loadHistory();
}
};
const tabBar = tabs[0] && tabs[0].parentElement;
if (tabBar) {
tabBar.addEventListener('click', (e) => handle(e.target));
} else {
// Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t)));
}
})();
// --- Hoster selection ---
function accountHasCreds(name, account) {
if (!account) return false;
if (account.authType === 'api') return !!account.apiKey;
if (account.authType === 'login') return !!(account.username && account.password);
// Fallback
if (name === 'vidmoly.me') return !!(account.username && account.password);
if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey;
return !!account.apiKey;
}
// Returns hosters that have at least one enabled account with credentials
function getAvailableHosters() {
const result = [];
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
if (hasEnabledAccount) result.push({ name });
}
return result;
}
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 accounts = config.hosters[name];
return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
});
}
}
function getSelectedHosters() {
return selectedUploadHosters.slice();
}
function getHosterLabel(name) {
const labels = {
'doodstream.com': 'Doodstream',
'voe.sx': 'VOE',
'vidmoly.me': 'Vidmoly',
'byse.sx': 'Byse',
'clouddrop.cc': 'Clouddrop'
};
return labels[name] || name;
}
function getAccountAuthLabel(account) {
if (!account) return '';
if (account.authType === 'api') return 'API';
if (account.authType === 'login') return 'Web Login';
return '';
}
function getAccountDisplayName(name, account) {
const authLabel = getAccountAuthLabel(account);
return authLabel
? `${getHosterLabel(name)} (${authLabel})`
: 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 { account } of getAllAccountsFlat()) {
if (account.id) {
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
}
}
accountStatuses = nextStatuses;
}
// Returns flat array of all accounts: [{ name, account, index }]
function getAllAccountsFlat() {
const result = [];
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
accounts.forEach((account, index) => result.push({ name, account, index }));
}
return result;
}
// Returns flat array of accounts with credentials
function getAccountsWithCredsFlat() {
return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account));
}
// Find account by ID across all hosters
function findAccountById(accountId) {
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
const account = accounts.find(a => a.id === accountId);
if (account) return { name, account };
}
return null;
}
function scheduleStartupAccountCheck() {
const accounts = getAccountsWithCredsFlat();
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: ${getHosterLabel(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);
// Get first enabled account's status for subtitle
const accounts = config.hosters[item.name] || [];
const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a));
const accountCount = enabledAccounts.length;
let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`;
// Check if any account has ok status
const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok');
const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error');
if (hasOk) subtitle += ' • Bereit';
else if (hasError) subtitle += ' • Fehler';
return `
<label class="hoster-option${checked ? ' selected' : ''}" data-hoster-option="${item.name}">
<input type="checkbox" data-hoster-modal="${item.name}" ${checked ? 'checked' : ''}>
<div class="hoster-option-main">
<div class="hoster-option-title">${escapeHtml(getHosterLabel(item.name))}</div>
<div class="hoster-option-subtitle">${subtitle}</div>
</div>
</label>
`;
}).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
const pendingPaths = new Set(_pendingFiles.map(f => f.path));
if (_pendingFiles.length > 0) {
selectedFiles.push(..._pendingFiles);
_pendingFiles = [];
}
renderHosterSummary();
// During an active upload, build preview jobs for the new files and inject
// them into the running batch immediately (otherwise they'd be lost on
// handleBatchDone via syncSelectedFilesFromQueue)
if (uploading && pendingPaths.size > 0) {
buildQueuePreview(); // creates 'preview' jobs for new files
const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file));
if (newJobs.length > 0) {
newJobs.forEach(j => { j.status = 'queued'; });
renderQueueTable();
window.api.addJobsToBatch({
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
persistQueueStateSoon(true);
}
}
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 }))
: [];
const rawJobs = 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
}))
: [];
// Deduplicate: keep the job with the best status for each file+hoster pair
const seen = new Map();
const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 };
for (const job of rawJobs) {
const key = `${job.file}|${job.hoster}`;
const existing = seen.get(key);
if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) {
seen.set(key, job);
}
}
queueJobs = Array.from(seen.values());
rebuildJobIndex();
}
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');
let _dragCounter = 0;
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; 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) {
// Build path-Set once so dedup is O(1) per candidate instead of O(n+m).
// Matters when the user picks a folder with thousands of files.
const existing = new Set();
for (const f of selectedFiles) existing.add(f.path);
for (const f of _pendingFiles) existing.add(f.path);
const newFiles = [];
for (const p of paths) {
if (existing.has(p)) continue;
existing.add(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 hasQueuedJobs = queueJobs.some(isStartableQueueJob);
const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0;
btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
}
const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']);
const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
function updateQueueActionButtons() {
updateStartButton();
const hasSelection = selectedJobIds.size > 0;
// Single pass over the (usually small) selection set instead of three O(n)
// scans over the entire queue. For 1000 jobs × 3 scans this drops the
// selection-change cost from ~3000 checks to |selection|.
let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false;
for (const id of selectedJobIds) {
const job = _jobIndexById.get(id);
if (!job) continue;
const s = job.status;
if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true;
if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true;
if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true;
if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break;
}
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;
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) && !_completedUploadKeys.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);
// Track deletion so handleProgress() won't re-create this job from stale callbacks
_deletedJobIds.add(job.id);
if (job.uploadId) _deletedJobIds.add(job.uploadId);
// Allow re-uploading same file+hoster after deletion
if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`);
}
// --- 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(); });
}
let _recentRenderQueued = false;
function scheduleRecentRender() {
if (_recentRenderQueued) return;
_recentRenderQueued = true;
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
}
// Toggle the .selected class on existing rows without rebuilding the table.
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
function applyQueueSelectionClasses() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.queue-row');
for (const tr of rows) {
tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId));
}
}
function applyRecentSelectionClasses() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.recent-file-row');
for (const tr of rows) {
const order = parseInt(tr.dataset.order, 10);
tr.classList.toggle('selected', selectedRecentIds.has(order));
}
}
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 `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
<td class="col-size">${uploadedSize}</td>
<td class="col-host">${escapeHtml(job.hoster)}</td>
<td class="col-status"><span class="status-badge ${statusClass}">${escapeHtml(statusText)}</span></td>
<td class="col-elapsed">${elapsed}</td>
<td class="col-remaining">${remaining}</td>
<td class="col-speed">${speed}</td>
<td class="col-progress">
<div class="progress-cell">
<div class="progress-bar-bg">
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
</div>
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
</div>
</td>
</tr>`;
}
// 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 || '') : '';
// Write DOM only when the target value actually changes — a no-op progress
// tick (same pct, same speed) then performs zero DOM work. Massive saver
// when most of the visible jobs are idle/queued/done and only a few are
// actively uploading.
const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
if (tr.className !== newClass) tr.className = newClass;
if (tr.dataset.link !== link) tr.dataset.link = link;
const cells = tr.children;
if (cells.length < 8) return false; // structure mismatch, needs full rebuild
if (cells[1].textContent !== uploadedSize) 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) {
const badgeClass = `status-badge ${statusClass}`;
if (badge.className !== badgeClass) badge.className = badgeClass;
if (badge.textContent !== statusText) badge.textContent = statusText;
}
if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed;
if (cells[5].textContent !== remaining) cells[5].textContent = remaining;
if (cells[6].textContent !== speed) cells[6].textContent = speed;
const fill = cells[7].querySelector('.progress-bar-fill');
if (fill) {
const pctStr = pct + '%';
if (fill.style.width !== pctStr) fill.style.width = pctStr;
const fillClass = `progress-bar-fill ${statusClass}`;
if (fill.className !== fillClass) fill.className = fillClass;
}
const pctSpan = cells[7].querySelector('.progress-pct');
if (pctSpan) {
const pctText = job.status === 'preview' ? '' : pct + '%';
if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText;
}
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 — in-place update when range unchanged
_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);
// Same range — try in-place update to avoid hover flicker
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) {
const rows = tbody.querySelectorAll('.queue-row');
if (rows.length === endIdx - startIdx) {
let allMatch = true;
for (let i = 0; i < rows.length; i++) {
const job = _sortedJobsCache[startIdx + i];
if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; }
_updateRowInPlace(rows[i], job);
}
if (allMatch) return; // all rows updated in-place, no rebuild needed
}
}
_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 += `<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="8"></td></tr>`;
for (let i = startIdx; i < endIdx; i++) {
html += buildRowHtml(_sortedJobsCache[i]);
}
if (bottomPad > 0) html += `<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="8"></td></tr>`;
tbody.innerHTML = html;
}
// Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one
// render per frame. rAF keeps the scroll thread cheap.
let _queueScrollQueued = false;
function _onQueueScroll() {
if (_queueScrollQueued) return;
if (_sortedJobsCache.length < 200) return;
_queueScrollQueued = true;
requestAnimationFrame(() => {
_queueScrollQueued = false;
const tbody = document.getElementById('queueBody');
if (tbody) _renderVirtualRows(tbody);
});
}
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
const _collatorSimple = new Intl.Collator('de');
// Queue sort memoization. Keys that don't change after a job enters the queue
// (filename, host) reuse the cached result across progress-driven re-renders.
// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when
// previews resolve / upload starts) are recomputed each call — otherwise a
// queue sorted by size during previews would be stuck in all-zeros order.
let _queueSortCache = { sig: '', result: [] };
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
function sortQueueJobs(jobs) {
const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1;
const canCache = _STATIC_SORT_KEYS.has(key);
const sig = canCache ? `${key}|${direction}|${jobs.length}` : '';
if (sig && _queueSortCache.sig === sig) return _queueSortCache.result;
const sorted = jobs.slice().sort((a, b) => {
let cmp = 0;
if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName);
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, 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;
});
if (sig) _queueSortCache = { sig, result: sorted };
return sorted;
}
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;
// Clear recent panel selection when clicking in queue — class-toggle only.
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); }
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 = _jobIndexById.get(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');
}
}
}
// Selection changes don't change sort order / row content — just toggle classes.
applyQueueSelectionClasses();
updateQueueActionButtons();
}
// --- Context menu ---
let alwaysOnTopState = false;
// Cache hoster-counts for the context menu. Invalidated on structural changes
// to queueJobs (the length-based signature is good enough — a job's hoster
// never changes after it's created).
let _hosterCountsCache = { sig: '', result: new Map() };
function _getHosterCounts() {
const sig = `${queueJobs.length}`;
if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result;
const m = new Map();
for (let i = 0; i < queueJobs.length; i++) {
const h = queueJobs[i].hoster;
m.set(h, (m.get(h) || 0) + 1);
}
_hosterCountsCache = { sig, result: m };
return m;
}
function handleRowContextMenu(e, row) {
e.preventDefault();
const jobId = row.dataset.jobId;
if (!selectedJobIds.has(jobId)) {
selectedJobIds.clear();
selectedJobIds.add(jobId);
applyQueueSelectionClasses();
updateQueueActionButtons();
}
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';
// Dynamic "delete by hoster" submenu — cached count keyed by queue length
// so a right-click on a 5000-job queue doesn't rescan everything.
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
const hosterCounts = _getHosterCounts();
deleteHosterContainer.innerHTML = '';
if (hosterCounts.size > 0) {
deleteHosterSubmenu.style.display = '';
hosterCounts.forEach((count, hoster) => {
const item = document.createElement('div');
item.className = 'ctx-item ctx-item-danger';
item.dataset.action = `delete-hoster:${hoster}`;
item.textContent = `${getHosterLabel(hoster)} (${count})`;
deleteHosterContainer.appendChild(item);
});
} else {
deleteHosterSubmenu.style.display = 'none';
}
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 => {
// Temporarily show to measure actual width (display:none → offsetWidth=0)
sub.style.visibility = 'hidden';
sub.style.display = 'block';
sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth);
sub.style.display = '';
sub.style.visibility = '';
});
}
function hideContextMenu() {
document.getElementById('contextMenu').style.display = 'none';
document.getElementById('recentContextMenu').style.display = 'none';
}
function deleteSelectedRecentFiles() {
if (selectedRecentIds.size === 0) return;
let removedDone = 0, removedErr = 0;
sessionFilesData = sessionFilesData.filter(r => {
if (!selectedRecentIds.has(r.order)) return true;
if (r.isError) removedErr++; else removedDone++;
_sessionFileKeys.delete(`${r.link}\u0001${r.filename}\u0001${r.host}`);
return false;
});
_sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone);
_sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr);
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
function clearAllRecentFiles() {
if (sessionFilesData.length === 0) return;
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
sessionFilesData = [];
_sessionFileKeys.clear();
_sessionDoneCount = 0;
_sessionErrorCount = 0;
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
async function exportAllRecentFiles() {
if (sessionFilesData.length === 0) {
alert('Keine Einträge zum Exportieren.');
return;
}
const rows = sortRecentFiles(sessionFilesData);
const header = 'timestamp|hoster|link|filename|status';
const lines = rows.map(r => {
const ts = r.timestamp || r.time || '';
const host = r.host || r.hoster || '';
const link = r.link || '';
const name = r.filename || '';
const status = r.isError ? 'error' : 'ok';
return [ts, host, link, name, status].map(v => String(v).replace(/[\r\n|]/g, ' ')).join('|');
});
const content = [header, ...lines].join('\n') + '\n';
const defaultName = `uploads-${new Date().toISOString().slice(0, 10)}.log`;
try {
const result = await window.api.saveTextFile(defaultName, content, [
{ name: 'Log-Datei', extensions: ['log', 'txt', 'csv'] }
]);
if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`);
} catch (err) {
alert('Export fehlgeschlagen: ' + (err.message || err));
}
}
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 export / import ---
async function doBackupExport() {
try {
const result = await window.api.exportBackup();
if (result && result.ok) showCopyToast('Backup exportiert');
} catch (err) {
alert('Export fehlgeschlagen: ' + (err.message || err));
}
}
function askLegacyBackupPassword() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
const card = document.createElement('div');
card.className = 'modal-card';
card.style.width = 'min(380px,100%)';
const header = document.createElement('div');
header.className = 'modal-header';
const h3 = document.createElement('h3');
h3.textContent = 'Passwort erforderlich';
header.appendChild(h3);
const body = document.createElement('div');
body.className = 'modal-body';
const p = document.createElement('p');
p.style.margin = '0 0 10px';
p.style.fontSize = '13px';
p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.';
const input = document.createElement('input');
input.type = 'password';
input.className = 'key-input';
input.placeholder = 'Passwort';
input.autocomplete = 'off';
input.style.width = '100%';
body.appendChild(p);
body.appendChild(input);
const footer = document.createElement('div');
footer.className = 'modal-footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-secondary';
cancelBtn.textContent = 'Abbrechen';
const okBtn = document.createElement('button');
okBtn.className = 'btn btn-primary';
okBtn.textContent = 'Importieren';
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
card.appendChild(header);
card.appendChild(body);
card.appendChild(footer);
overlay.appendChild(card);
document.body.appendChild(overlay);
const done = (val) => { overlay.remove(); resolve(val); };
okBtn.onclick = () => done(input.value || null);
cancelBtn.onclick = () => done(null);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') done(input.value || null);
if (e.key === 'Escape') done(null);
});
input.focus();
});
}
async function doBackupImport(legacyPassword) {
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
try {
const result = await window.api.importBackup(pw);
if (!result || result.canceled) return;
if (result.needsPassword) {
const entered = await askLegacyBackupPassword();
if (entered) doBackupImport(entered);
return;
}
if (result.ok) {
config = result.config;
hosterSettings = config.hosterSettings || {};
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
window.api.setAlwaysOnTop(alwaysOnTopState);
renderSettings();
renderAccounts();
renderHosterSummary();
renderHosterModal();
loadHistory();
showCopyToast('Backup importiert');
} else if (result.error) {
alert('Import fehlgeschlagen: ' + result.error);
}
} catch (err) {
alert('Import fehlgeschlagen: ' + (err.message || err));
}
}
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();
// Select recent files only if user's last interaction was in the recent panel
if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) {
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) {
const deletedIds = [...selectedJobIds];
// Cancel active uploads for deleted jobs
const activeIds = deletedIds.filter(id => {
const j = _jobIndexById.get(id);
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
});
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
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(true);
}
}
}
});
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') {
// Cancel active uploads for deleted jobs
const activeIds = [...selectedJobIds].filter(id => {
const j = _jobIndexById.get(id);
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
});
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
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(true);
} else if (action === 'copy-all-links') {
copyAllLinks();
} else if (action === 'delete-all') {
// Cancel all active uploads
const activeIds = queueJobs
.filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server')
.map(j => j.id);
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
queueJobs.forEach(j => removeJobFromIndex(j));
queueJobs = [];
selectedJobIds.clear();
selectedFiles = [];
syncSelectedFilesFromQueue();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
} else if (action === 'always-on-top') {
alwaysOnTopState = !alwaysOnTopState;
await window.api.setAlwaysOnTop(alwaysOnTopState);
} else if (action.startsWith('delete-hoster:')) {
const hoster = action.replace('delete-hoster:', '');
// Cancel active uploads for this hoster
const activeIds = queueJobs
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
.map(j => j.id);
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
// Remove ALL jobs for this hoster
queueJobs = queueJobs.filter(j => {
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
return true;
});
selectedJobIds.clear();
syncSelectedFilesFromQueue();
renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar();
updateQueueActionButtons();
persistQueueStateSoon(true);
} 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 (uploading) return;
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
const hosters = getSelectedHosters();
if (queueJobs.length === 0 && selectedFiles.length > 0) {
if (hosters.length === 0) {
alert('Bitte mindestens einen Hoster auswählen.');
uploading = false;
updateQueueActionButtons();
return;
}
buildQueuePreview();
}
const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
try {
jobsToStart.forEach(j => {
j.status = 'queued';
j.error = null;
j.result = null;
j.bytesUploaded = 0;
j.speedKbs = 0;
j.elapsed = 0;
j.remaining = 0;
j.progress = 0;
j.uploadId = null;
});
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);
_markSkippedJobs(result);
persistQueueStateSoon();
if (result && result.error) {
alert(result.error);
uploading = false;
updateQueueActionButtons();
updateStatusBar();
}
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
}
}
function _markSkippedJobs(result) {
if (!result || !Array.isArray(result.skippedJobs) || result.skippedJobs.length === 0) return;
for (const skipped of result.skippedJobs) {
const job = _jobIndexById.get(skipped.jobId);
if (job) {
job.status = 'error';
job.error = skipped.reason || 'Kein gültiger Account';
}
}
renderQueueTable();
}
async function startSelectedUpload() {
if (uploading) {
// Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch
// Upload-manager has duplicate protection (skips jobs already tracked)
const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status));
if (addable.length > 0) {
addable.forEach(j => {
j.status = 'queued'; j.error = null; j.result = null;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
});
renderQueueTable();
let result = null;
try {
result = await window.api.addJobsToBatch({
jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
});
} catch (err) {
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`);
return;
}
// If the batch ended between UI-state and IPC call, start a fresh batch immediately
if (result && result.error === 'Kein Upload aktiv') {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
await startSelectedUpload();
return;
}
_markSkippedJobs(result);
persistQueueStateSoon();
const added = Number(result && result.added) || 0;
// Use ASCII-only toast text here to avoid encoding artifacts on some systems.
const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0;
const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds)
? result.alreadyInBatchJobIds.length
: Math.max(0, addable.length - added - skipped);
const toastParts = [];
if (added > 0) toastParts.push(`${added} hinzugefuegt`);
if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`);
if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`);
if (result && result.error) {
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`);
} else if (toastParts.length > 0) {
showCopyToast(`Jobs: ${toastParts.join(', ')}`);
} else {
showCopyToast('Keine Jobs hinzugefuegt');
}
return;
}
return;
}
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
const hosters = getSelectedHosters();
const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
try {
jobsToStart.forEach(j => {
j.status = 'queued';
j.error = null;
j.result = null;
j.bytesUploaded = 0;
j.speedKbs = 0;
j.progress = 0;
j.uploadId = null;
});
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);
_markSkippedJobs(result);
persistQueueStateSoon();
if (result && result.error) {
alert(result.error);
uploading = false;
updateQueueActionButtons();
updateStatusBar();
}
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
}
}
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) {
// Don't re-create jobs that were explicitly deleted by the user
if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) {
return;
}
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);
}
// Don't regress from terminal states (stale callbacks can arrive after completion)
if (job.status === 'done' || job.status === 'skipped') return;
// Update job state
job.status = data.status;
job.bytesUploaded = data.bytesUploaded || 0;
job.bytesTotal = data.bytesTotal || job.bytesTotal;
// Track session total bytes (survives removeFromQueueOnDone)
if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) {
_sessionTotalBytes += job.bytesTotal;
_sessionTrackedJobs.add(job.id);
}
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);
// Track session uploaded bytes (survives removeFromQueueOnDone)
if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) {
_sessionUploadedBytes += job.bytesTotal || 0;
_sessionDoneJobs.add(job.id);
}
// Track completed uploads so they don't get re-queued after removal
if (job.status === 'done') {
_completedUploadKeys.add(`${job.file}|${job.hoster}`);
}
// 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);
_deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes
// 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();
// History is only visible on the Verlauf tab. Mark it dirty and refresh when
// the user actually switches to it — skips an IPC + full table rebuild per
// batch-done when the user is watching the upload view.
_historyDirty = true;
if (_isHistoryTabActive()) loadHistory();
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
if (removeOnDone) {
// Single pass: build the keep-list and clean up the index for removed jobs.
const nextJobs = [];
for (const job of queueJobs) {
if (job.status === 'done') {
removeJobFromIndex(job);
selectedJobIds.delete(job.id);
} else {
nextJobs.push(job);
}
}
queueJobs = nextJobs;
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 = [];
// Build a Set for O(1) selectedFiles dedup below.
const existingFilePaths = new Set();
for (const f of selectedFiles) existingFilePaths.add(f.path);
queueJobs.forEach(j => {
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
// Invalidate the old uploadId: retire the index entry and mark it so
// any late progress event from the previous (cancelled/completed)
// upload can't overwrite the freshly-reset state.
if (j.uploadId) {
_jobIndexByUploadId.delete(j.uploadId);
_deletedJobIds.add(j.uploadId);
}
j.status = uploading ? 'queued' : '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 (!existingFilePaths.has(j.file)) {
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
existingFilePaths.add(j.file);
}
}
});
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;
const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`;
if (!_sessionFileKeys.has(dedupKey)) {
_sessionFileKeys.add(dedupKey);
sessionFilesData.push({
date: dt.text,
dateTs: dt.ts,
filename: job.fileName || '',
host: job.hoster || '',
link,
isError: false,
order: sessionFilesData.length
});
_sessionDoneCount++;
// Coalesce rapid successive adds into one render per frame.
scheduleRecentRender();
}
}
}
function applySummaryResults(summary) {
const files = Array.isArray(summary?.files) ? summary.files : [];
// Build a (fileName + hoster) → job map once so the per-result lookup is O(1)
// instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters)
// otherwise become O(n²).
const jobByKey = new Map();
for (const j of queueJobs) {
jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j);
}
for (const file of files) {
for (const result of file.results || []) {
const job = jobByKey.get(`${file.name}\u0001${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).
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
//
// Memoized within a single tick: back-to-back calls (updateStatusBar +
// updateStatsPanel fire together 4×/sec during upload) share one scan. The
// cache is cleared on microtask so the next tick picks up fresh state.
let _queueStatsCache = null;
function _computeQueueStats() {
if (_queueStatsCache) return _queueStatsCache;
let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 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++;
inProgressBytes += bu;
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);
}
}
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
queueMicrotask(() => { _queueStatsCache = null; });
return _queueStatsCache;
}
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 = _sessionUploadedBytes + stats.inProgressBytes;
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
}
// --- Health Check ---
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 `<div class="health-badge ${status}">
<span>${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')}</span>
<span class="health-tag">[${status.toUpperCase()}]</span>
<span>${escapeHtml(item.message || '')}</span>
</div>`;
}).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) return;
const key = row.accountId || row.hoster;
if (key) {
accountStatuses[key] = {
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 [];
// Build check list: all enabled accounts with creds
let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters;
} else {
hosters = getAccountsWithCredsFlat()
.filter(({ account }) => account.enabled !== false)
.map(({ name, account }) => ({ hoster: name, accountId: account.id }));
}
if (hosters.length === 0) {
if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.');
return [];
}
healthCheckRunning = true;
// Mark all accounts as checking
for (const h of hosters) {
const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
accountStatuses[key] = { 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 = getAvailableHosters();
const generalPanel = document.createElement('div');
generalPanel.className = 'hoster-settings-panel';
generalPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="global">
<span class="panel-arrow">&#9660;</span>
<span class="panel-title">Allgemein</span>
<span class="panel-status active">System</span>
</div>
<div class="hoster-panel-body" data-panel="global" style="display:block">
<div class="settings-section-label">Uploads</div>
<div class="settings-row">
<label style="min-width:185px">Globale parallele Uploads</label>
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100" style="width:80px">
<span class="hint">0 = nur pro Hoster</span>
</div>
<div class="settings-row">
<label style="min-width:185px">Globales Speed-Limit (MB/s)</label>
<input type="number" class="hs-input settings-autosave" id="globalMaxSpeedMbsInput" value="${globalSettings.globalMaxSpeedKbs > 0 ? (globalSettings.globalMaxSpeedKbs / 1024).toFixed(2).replace(/\\.00$/, '') : '0'}" min="0" step="0.1" style="width:80px">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Immer im Vordergrund</label>
<input type="checkbox" class="settings-autosave" id="alwaysOnTopInput" ${alwaysOnTopState ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Hoster-Limits hochskalieren</label>
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Aus Queue entfernen bei Abschluss</label>
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Queue beim Start wiederherstellen</label>
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
</div>
<div class="settings-row checkbox-row">
<label>Drop-Target anzeigen</label>
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
</div>
</div>
<div class="settings-section-label">Updates</div>
<div class="settings-row">
<label>Manuell prüfen</label>
<button class="btn btn-xs btn-secondary" id="manualUpdateCheckBtn">Nach Updates suchen</button>
</div>
<div class="settings-section-label">Log</div>
<div class="settings-row">
<label>FileUploader Log</label>
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
<button class="btn btn-xs btn-secondary" id="openLogFolderBtn" title="Log-Ordner im Explorer öffnen">Öffnen</button>
</div>
<div class="settings-row">
<label>Neues Log pro Tag</label>
<input type="checkbox" class="settings-autosave" id="sessionLogInput" ${globalSettings.sessionLog ? 'checked' : ''}>
</div>
</div>
`;
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 ? '&#9654;' : '&#9660;';
});
// --- Folder Monitor Panel ---
const fm = globalSettings.folderMonitor || {};
const folderMonitorPanel = document.createElement('div');
folderMonitorPanel.className = 'hoster-settings-panel';
folderMonitorPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="folderMonitor">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Ordnerüberwachung</span>
<span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span>
</div>
<div class="hoster-panel-body" data-panel="folderMonitor" style="display:none">
<div class="settings-section-label">Ordner</div>
<div class="settings-row">
<label>Ordnerpfad</label>
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen..." style="flex:1">
<button class="btn btn-xs btn-secondary" id="fmChooseFolderBtn">Wählen</button>
</div>
<div class="settings-row">
<label>Dateierweiterungen</label>
<select class="hs-input settings-autosave" id="fmFilterModeInput" style="width:auto;margin-right:6px">
<option value="include" ${fm.filterMode === 'include' ? 'selected' : ''}>Nur diese</option>
<option value="exclude" ${fm.filterMode === 'exclude' ? 'selected' : ''}>Alle außer</option>
</select>
<input type="text" class="key-input settings-autosave" id="fmExtensionsInput" value="${escapeAttr(fm.extensions || '')}" placeholder="mp4,mkv,avi" style="flex:1">
</div>
<div class="settings-row">
<label>Verzögerung (Sekunden)</label>
<input type="number" class="hs-input settings-autosave" id="fmDelaySecInput" value="${fm.delaySec ?? 3}" min="1" max="300" style="width:80px">
<span class="hint">Warten bis Datei fertig geschrieben</span>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
<input type="checkbox" class="settings-autosave" id="fmEnabledInput" ${fm.enabled ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Unterordner einbeziehen</label>
<input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Duplikate überspringen</label>
<input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Auto-Upload starten</label>
<input type="checkbox" class="settings-autosave" id="fmAutoStartInput" ${fm.autoStart !== false ? 'checked' : ''}>
</div>
</div>
<div class="settings-section-label">Hoster-Vorauswahl</div>
<div class="settings-grid-mini">
${configuredAccounts.map(({ name }) => `
<div class="settings-row checkbox-row">
<label>${escapeHtml(name)}</label>
<input type="checkbox" class="settings-autosave fm-hoster-checkbox" data-fm-hoster="${name}" ${(fm.hosters || []).includes(name) ? 'checked' : ''}>
</div>`).join('')}
</div>
${configuredAccounts.length === 0 ? '<p class="hint" style="margin:0">Erst Accounts anlegen, dann hier auswählen.</p>' : '<p class="hint" style="margin:2px 0 0">Keine Auswahl = Hoster-Modal bei jeder Datei.</p>'}
</div>
`;
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 ? '&#9654;' : '&#9660;';
});
// 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();
}
});
// --- Remote Control Panel ---
const remoteSettings = globalSettings.remote || {};
const remotePanel = document.createElement('div');
remotePanel.className = 'hoster-settings-panel';
remotePanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="remote">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Fernsteuerung</span>
<span class="panel-status${remoteSettings.enabled ? ' active' : ''}" id="remoteStatusBadge">${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}</span>
</div>
<div class="hoster-panel-body" data-panel="remote" style="display:none">
<div class="settings-section-label">Server</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
<input type="checkbox" class="settings-autosave" id="remoteEnabledInput" ${remoteSettings.enabled ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Input erlauben</label>
<input type="checkbox" class="settings-autosave" id="remoteAllowInputInput" ${remoteSettings.allowInput !== false ? 'checked' : ''}>
</div>
</div>
<div class="settings-row">
<label>Port</label>
<input type="number" class="hs-input settings-autosave" id="remotePortInput" value="${remoteSettings.port || 9100}" min="1024" max="65535" style="width:100px">
</div>
<div class="settings-row">
<label>API-Token</label>
<input type="text" class="key-input" id="remoteTokenInput" value="${escapeAttr(remoteSettings.token || '')}" readonly style="flex:1">
<button class="btn btn-xs btn-secondary" id="remoteCopyTokenBtn" title="Kopieren">Kopieren</button>
<button class="btn btn-xs btn-secondary" id="remoteRegenerateTokenBtn" title="Neu generieren">Neu</button>
</div>
<div class="settings-section-label">Status</div>
<div class="settings-row">
<span id="remoteConnectionStatus" style="color:#94a3b8">Prüfe...</span>
</div>
</div>
`;
container.appendChild(remotePanel);
// Toggle remote panel
remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = remotePanel.querySelector('.hoster-panel-body');
const arrow = remotePanel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
// Copy token
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
const token = document.getElementById('remoteTokenInput').value;
if (token) {
await window.api.copyToClipboard(token);
document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!';
setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500);
}
});
// Regenerate token
document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => {
const newToken = await window.api.remoteGenerateToken();
document.getElementById('remoteTokenInput').value = newToken;
scheduleSettingsSave();
});
// Update status
window.api.remoteStatus().then(status => {
const el = document.getElementById('remoteConnectionStatus');
if (!el) return;
if (status.running) {
el.textContent = `Aktiv auf Port ${status.port} ${status.clientCount} Client(s) verbunden`;
el.style.color = '#10b981';
} else {
el.textContent = 'Nicht aktiv';
el.style.color = '#94a3b8';
}
}).catch(() => {});
// Live client count updates (listener registered once in init, not here)
// --- Backup Panel ---
const backupPanel = document.createElement('div');
backupPanel.className = 'hoster-settings-panel';
backupPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="backup">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Backup</span>
<span class="panel-status active">System</span>
</div>
<div class="hoster-panel-body" data-panel="backup" style="display:none">
<p class="hint" style="margin:0 0 10px">Alle Accounts, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.</p>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
</div>
</div>
`;
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 ? '&#9654;' : '&#9660;';
});
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
// --- 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 = '<p>Noch keine Account-Einstellungen vorhanden.</p><span class="hint">Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.</span>';
container.appendChild(empty);
}
for (const { name } 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 = `
<div class="hoster-panel-header" data-hoster="${name}">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">${escapeHtml(getHosterLabel(name))}</span>
<span class="panel-status active">Aktiv</span>
</div>
<div class="hoster-panel-body" data-panel="${name}" style="display:none">
<h4>Upload-Einstellungen</h4>
<div class="settings-grid-mini">
<div class="settings-row">
<label>Retries</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500">
</div>
<div class="settings-row">
<label>Max Speed (MB/s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSpeedMbs" value="${maxSpeedMbs}" min="0" step="0.1">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-row">
<label>Parallele Uploads</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="parallelCount" value="${hs.parallelCount ?? 2}" min="1" max="100">
</div>
<div class="settings-row">
<label>Restart unter (kB/s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="restartBelowKbs" value="${hs.restartBelowKbs ?? 0}" min="0">
<span class="hint">0 = aus</span>
</div>
<div class="settings-row">
<label>Intervall (s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="timeIntervalSec" value="${hs.timeIntervalSec ?? 0}" min="0">
</div>
<div class="settings-row">
<label>Max Size (MB)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSizeMb" value="${hs.maxSizeMb ?? 0}" min="0">
<span class="hint">0 = unbegrenzt</span>
</div>
</div>
</div>
`;
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 ? '&#9654;' : '&#9660;';
});
}
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
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,
showDropTarget: !!document.getElementById('showDropTargetInput')?.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)
},
remote: {
enabled: !!document.getElementById('remoteEnabledInput')?.checked,
port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)),
token: (document.getElementById('remoteTokenInput')?.value || '').trim(),
allowInput: !!document.getElementById('remoteAllowInputInput')?.checked
}
};
// 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);
}
}
// Drop target window
const dtCheckbox = document.getElementById('showDropTargetInput');
if (dtCheckbox) {
if (dtCheckbox.checked) await window.api.showDropTarget();
else await window.api.hideDropTarget();
}
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;
}
// Fire both saves in parallel instead of serializing the two IPC round-trips.
// Skip the getConfig refetch — we just wrote it, we know the new state, and
// the round-trip added 100200ms of UI stall per keystroke (autosave fires
// on every input change).
await Promise.all([
window.api.saveHosterSettings(newHosterSettings),
window.api.saveGlobalSettings(globalSettings)
]);
config.hosterSettings = newHosterSettings;
config.globalSettings = globalSettings;
hosterSettings = newHosterSettings;
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'; }
}
// Start/stop remote server based on settings
const remoteSettings = globalSettings.remote;
const remoteBadge = document.getElementById('remoteStatusBadge');
if (remoteSettings) {
try {
await window.api.remoteSaveSettings(remoteSettings);
if (remoteBadge) {
remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv';
remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`;
}
// Update status display
const status = await window.api.remoteStatus();
const statusEl = document.getElementById('remoteConnectionStatus');
if (statusEl) {
if (status.running) {
statusEl.textContent = `Aktiv auf Port ${status.port} ${status.clientCount} Client(s) verbunden`;
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = 'Nicht aktiv';
statusEl.style.color = '#94a3b8';
}
}
} catch {}
}
const feedback = document.getElementById('saveFeedback');
feedback.textContent = feedbackText;
setTimeout(() => {
if (feedback.textContent === feedbackText) {
feedback.textContent = 'Änderungen werden automatisch gespeichert.';
}
}, 1800);
}
// --- Accounts ---
function getCredentialLabel(name, account) {
if (!account) return 'Keine Zugangsdaten';
if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`;
if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`;
// Fallback
if (account.username && account.password) return `Login: ${account.username}`;
if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`;
return 'Keine Zugangsdaten';
}
const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
function _buildAccountCardHtml(name, account, idx) {
const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
const statusClass = isDisabled ? 'disabled' : st.status;
const credLabel = getCredentialLabel(name, account);
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
return `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
${statusLabel}
</span>
<div class="account-card-actions">
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div>
</div>`;
}
// Replace only the one card for `accountId` instead of re-rendering the whole
// container. Runs on enable/disable, single health check, priority-badge bumps
// after a reorder — anywhere we only change one card's state.
function updateAccountCard(accountId) {
const container = document.getElementById('accountsList');
if (!container) return;
const found = findAccountById(accountId);
if (!found) return;
const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`);
if (!card) return;
const accounts = config.hosters[found.name] || [];
const idx = accounts.findIndex(a => a.id === accountId);
if (idx < 0) return;
const tmp = document.createElement('div');
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
card.replaceWith(tmp.firstElementChild);
}
let _accountListenersBound = false;
function renderAccounts() {
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const allAccounts = getAllAccountsFlat();
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
if (allAccounts.length === 0) {
container.innerHTML = `
<div class="accounts-empty">
<p>Keine Accounts vorhanden</p>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
</div>`;
if (!_accountListenersBound) bindAccountListeners(container);
return;
}
const byHoster = {};
for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = [];
byHoster[name].push(account);
}
let html = '';
for (const name of HOSTERS) {
const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); });
html += '</div>';
}
container.innerHTML = html;
if (!_accountListenersBound) bindAccountListeners(container);
}
// Single set of delegated listeners on the accounts container. Bound once on
// the first render and reused for every subsequent in-place update / card
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
// per render — with 20 accounts that's 180 listener create/destroy cycles on
// every enable/disable click.
function bindAccountListeners(container) {
_accountListenersBound = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
});
let draggedCard = null;
container.addEventListener('dragstart', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card) return;
draggedCard = card;
card.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null;
container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
});
container.addEventListener('dragover', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card || !draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
card.classList.toggle('drag-over-above', e.clientY < midY);
card.classList.toggle('drag-over-below', e.clientY >= midY);
});
container.addEventListener('dragleave', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (card) card.classList.remove('drag-over-above', 'drag-over-below');
});
container.addEventListener('drop', (e) => {
const card = e.target.closest('.account-card[draggable]');
if (!card || !draggedCard || draggedCard === card) return;
e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below');
const hosterName = card.dataset.accountHoster;
if (draggedCard.dataset.accountHoster !== hosterName) return;
const draggedId = draggedCard.dataset.accountId;
const targetId = card.dataset.accountId;
const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return;
const fromIdx = accounts.findIndex(a => a.id === draggedId);
if (fromIdx < 0) return;
const [moved] = accounts.splice(fromIdx, 1);
const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Move the DOM node in place — no full re-render.
if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
// The Primär / Fallback #N badges just changed for the whole group.
for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id);
// Persist in the background. saveConfig is idempotent; we don't need to
// await here or re-fetch — our in-memory config is already the truth.
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
});
}
async function toggleAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
found.account.enabled = !found.account.enabled;
syncSelectedUploadHosters();
// In-place: swap only the one affected card. No full re-render, no IPC
// refetch, no flicker. Rapid click-toggles now feel instant even with 50
// accounts in the list.
updateAccountCard(accountId);
renderHosterSummary();
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
}
async function checkSingleAccount(accountId) {
if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found) return;
healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: '' };
updateAccountCard(accountId);
try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId);
if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' };
} catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
} finally {
healthCheckRunning = false;
}
updateAccountCard(accountId);
}
function getCredsFieldsHtml(authType, account) {
account = account || {};
if (authType === 'login') {
return `
<div class="settings-row">
<label>Username / E-Mail</label>
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="Username oder E-Mail">
</div>
<div class="settings-row">
<label>Passwort</label>
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`;
}
// API key
return `
<div class="settings-row">
<label>API Key</label>
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`;
}
function openAccountModal(editAccountId) {
editingAccountId = editAccountId || 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 (editingAccountId) {
// Edit mode
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none';
saveBtn.textContent = 'Speichern & prüfen';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account);
} else {
// Add mode — always show all options (multiple accounts per hoster allowed)
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';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join('');
const firstOpt = HOSTER_ADD_OPTIONS[0];
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {});
}
// 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';
_hideOtpField();
editingAccountId = null;
}
function openDeleteAccountModal(accountId) {
const found = findAccountById(accountId);
if (!found) return;
const modal = document.getElementById('deleteAccountModal');
const msg = document.getElementById('deleteAccountMessage');
msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`;
modal.dataset.accountId = accountId;
modal.style.display = 'flex';
}
function closeDeleteModal() {
document.getElementById('deleteAccountModal').style.display = 'none';
}
async function deleteAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
// Remove account from the array
const accounts = config.hosters[found.name];
if (Array.isArray(accounts)) {
config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
}
delete accountStatuses[accountId];
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
ensureAccountStatusEntries();
syncSelectedUploadHosters();
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
closeDeleteModal();
}
function readAccountCredsFromModal(authType) {
if (authType === 'login') {
const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim();
return { enabled: !!(username && password), authType: 'login', username, password };
}
// API
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!apiKey, authType: 'api', apiKey };
}
async function saveAccount() {
let hosterName, authType, accountId;
if (editingAccountId) {
// Edit existing account
const found = findAccountById(editingAccountId);
if (!found) return;
hosterName = found.name;
authType = found.account.authType || 'login';
accountId = editingAccountId;
} else {
// Add new account
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return;
hosterName = opt.hoster;
authType = opt.authType;
accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
const creds = readAccountCredsFromModal(authType);
if (!creds.enabled) {
const statusEl = document.getElementById('accountModalStatus');
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
statusEl.className = 'account-modal-status error';
return;
}
// Save credentials
if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = [];
if (editingAccountId) {
// Update existing account in array
const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId);
if (idx >= 0) {
config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds };
}
} else {
// Add new account
config.hosters[hosterName].push({ id: accountId, ...creds });
}
await window.api.saveConfig({ hosters: config.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[accountId] = { status: 'checking', message: '' };
syncSelectedUploadHosters();
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
// Check if OTP was entered (for retry after OTP prompt)
const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : '';
// Run health check for this specific account (include OTP if provided)
const checkPayload = { hoster: hosterName, accountId };
if (otp) checkPayload.otp = otp;
try {
const result = await window.api.runHealthCheck({ hosters: [checkPayload] });
const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId);
if (row && row.status === 'otp_required') {
// Show OTP input field if not already visible
accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' };
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error';
_showOtpField();
saveBtn.textContent = 'OTP bestätigen';
} else if (row && (row.status === 'ok' || row.status === 'warn')) {
accountStatuses[accountId] = { 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';
_hideOtpField();
setTimeout(() => closeAccountModal(), 1200);
} else {
const msg = (row && row.message) || 'Login fehlgeschlagen';
accountStatuses[accountId] = { status: 'error', message: msg };
statusEl.textContent = msg;
statusEl.className = 'account-modal-status error';
}
} catch (err) {
accountStatuses[accountId] = { 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();
}
}
function _showOtpField() {
if (document.getElementById('accField_otp')) return; // already visible
const container = document.getElementById('accountCredsFields');
const otpHtml = `
<div class="settings-row" id="otpFieldRow">
<label>OTP Code</label>
<input type="text" class="key-input" id="accField_otp" placeholder="6-stelliger Code aus E-Mail" autocomplete="one-time-code" inputmode="numeric" maxlength="10">
</div>`;
container.insertAdjacentHTML('beforeend', otpHtml);
// Auto-focus the OTP field
setTimeout(() => document.getElementById('accField_otp')?.focus(), 50);
}
function _hideOtpField() {
const row = document.getElementById('otpFieldRow');
if (row) row.remove();
}
// --- History ---
async function loadHistory() {
const history = await window.api.getHistory();
const container = document.getElementById('historyContainer');
if (!history || history.length === 0) {
historyRowsData = [];
container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
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);
}
async function exportHistory() {
const history = await window.api.getHistory();
if (!history || history.length === 0) {
alert('Kein Verlauf zum Exportieren vorhanden.');
return;
}
const asCsv = confirm('Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON');
const format = asCsv ? 'csv' : 'json';
const result = await window.api.exportHistory(format);
if (!result || result.canceled) return;
if (!result.ok) {
alert(result.error || 'Export fehlgeschlagen.');
return;
}
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
}
// Memoize sort result: invalidated only when data length changes or sort state changes.
// Selection changes and re-renders reuse the cached sorted array — a big win when
// the panel has thousands of rows and the sort is stable.
let _recentSortCache = { sig: '', result: [] };
function sortRecentFiles(data) {
const { key, direction } = recentSortState;
const sig = `${key}|${direction}|${data.length}`;
if (_recentSortCache.sig === sig) return _recentSortCache.result;
const sorted = data.slice();
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 * _collatorDE.compare(a.filename, b.filename);
if (key === 'host') return dir * _collatorDE.compare(a.host, b.host);
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
return 0;
});
_recentSortCache = { sig, result: sorted };
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 _buildRecentRowHtml(row) {
const cls = `recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}`;
return `<tr class="${cls}" data-order="${row.order}" data-link="${escapeAttr(row.link)}">`
+ `<td>${escapeHtml(row.date)}</td>`
+ `<td title="${escapeAttr(row.filename)}">${escapeHtml(row.filename)}</td>`
+ `<td>${escapeHtml(row.host)}</td>`
+ `<td title="${escapeAttr(row.link)}">${escapeHtml(row.link)}</td>`
+ `</tr>`;
}
// Tracks the last rendered dataset so we can append-only when the user is just
// accumulating new uploads (the default case: sort=date desc, rows only grow).
let _recentLastRenderedSig = '';
let _recentLastRenderedLen = 0;
function renderRecentUploadsPanel() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
if (!sessionFilesData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
_recentLastRenderedSig = '';
_recentLastRenderedLen = 0;
return;
}
const rows = sortRecentFiles(sessionFilesData);
const sig = `${recentSortState.key}|${recentSortState.direction}`;
const dateDescAppendOnly = sig === 'date|desc'
&& _recentLastRenderedSig === sig
&& rows.length > _recentLastRenderedLen
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
let wasAppendOnly = false;
if (dateDescAppendOnly) {
// Fast path: only new rows (date desc puts newest on top) — insert them
// at the top without rebuilding the 5000-row tbody below.
const added = rows.length - _recentLastRenderedLen;
let html = '';
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
tbody.insertAdjacentHTML('afterbegin', html);
wasAppendOnly = true;
} else {
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
}
_recentLastRenderedSig = sig;
_recentLastRenderedLen = rows.length;
// 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;
// Clear queue selection when clicking in recent panel — class-toggle only.
if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); }
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) {
// Reuse the already-sorted array from the sort cache instead of
// querying every .recent-file-row in the DOM (O(visible) vs O(N)
// on large panels).
const sortedOrders = (_recentSortCache.result || 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);
}
// Selection change — toggle classes, no tbody rebuild.
applyRecentSelectionClasses();
});
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'); }
});
}
// Sort headers only change when the sort state changes — skip on appends.
if (!wasAppendOnly) updateRecentSortHeaders();
}
function renderHistoryTable(container) {
if (!container || !historyRowsData.length) {
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
return;
}
const rows = sortHistoryRows(historyRowsData);
const headerCell = (key, label) => {
const active = historySortState.key === key;
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
};
let html = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody>`;
rows.forEach(row => {
html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}">
<td class="col-date">${escapeHtml(row.date)}</td>
<td class="col-filename">${escapeHtml(row.filename)}</td>
<td class="col-host">${escapeHtml(row.host)}</td>
<td class="col-link">${escapeHtml(row.link)}</td>
</tr>`;
});
html += '</tbody></table>';
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) => {
const cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || ''));
return (cmp || a.order - b.order) * factor;
});
}
// Flush pending queue state on window close (sync IPC — blocks until save completes)
window.addEventListener('beforeunload', () => {
// Flush pending settings save if user changed settings right before closing
if (settingsSaveTimer) {
clearTimeout(settingsSaveTimer);
settingsSaveTimer = null;
try { saveSettings(); } catch {}
}
clearTimeout(queuePersistTimer);
queuePersistTimer = null;
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: buildPersistedQueueState()
};
config.globalSettings = globalSettings;
window.api.saveGlobalSettingsSync(globalSettings);
});
// --- 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('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles);
document.getElementById('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles);
document.getElementById('retryFailedBtn').addEventListener('click', () => {
queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); });
retrySelectedJobs();
});
document.getElementById('importLogBtn').addEventListener('click', importUploadLog);
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);
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
if (!confirm('Verlauf wirklich löschen?')) return;
await window.api.clearHistory();
loadHistory();
});
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
// 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', (e) => {
// Don't sort if click was on the resizer handle
if (e.target.classList.contains('col-resizer')) return;
const key = th.dataset.sort;
if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc';
else { queueSortState.key = key; queueSortState.direction = 'asc'; }
_lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort
renderQueueTable();
});
});
// Queue table column resizing (JDownloader-style)
setupColumnResizing();
// 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 opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {});
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 accountId = modal.dataset.accountId;
if (accountId) deleteAccount(accountId);
});
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 persistQueueStateNow().catch(() => {}); // Save queue before update restart
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);
}
// --- Auto-deduplicate restored queue against own upload log on startup ---
async function _autoDeduplicateFromLog() {
if (queueJobs.length === 0) return;
try {
const entries = await window.api.readOwnUploadLog();
if (!entries || entries.length === 0) return;
const logKeys = new Set();
for (const entry of entries) {
logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`);
}
let removed = 0;
queueJobs = queueJobs.filter(job => {
const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`;
if (logKeys.has(key)) {
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
removed++;
return false;
}
return true;
});
if (removed > 0) {
rebuildJobIndex();
syncSelectedFilesFromQueue();
window.api.debugLog(`auto-dedup: removed ${removed} already-uploaded jobs from restored queue (${entries.length} log entries)`);
}
} catch {}
}
// --- Log import: remove already-uploaded file+hoster combos from queue ---
async function importUploadLog() {
const result = await window.api.importUploadLog();
if (!result || result.canceled) return;
const entries = result.entries || [];
if (entries.length === 0) {
showCopyToast('Keine Einträge im Log gefunden');
return;
}
// Build lookup Set: "filename_lower|hoster"
const logKeys = new Set();
for (const entry of entries) {
logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`);
}
// Find queue jobs that match (already uploaded)
let removed = 0;
queueJobs = queueJobs.filter(job => {
const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`;
if (logKeys.has(key) && job.status !== 'done') {
removeJobFromIndex(job);
// Mark as completed so buildQueuePreview won't re-create them
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
removed++;
return false;
}
return true;
});
if (removed > 0) {
selectedJobIds.clear();
syncSelectedFilesFromQueue();
rebuildJobIndex();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
}
showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`);
}
// --- 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; }
}
// --- Queue table column resizing (JDownloader-style) ---
function restoreQueueColumnWidths() {
try {
const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY);
if (!raw) return;
const widths = JSON.parse(raw);
if (!widths || typeof widths !== 'object') return;
for (const [col, px] of Object.entries(widths)) {
const th = document.querySelector(`#queueTable th[data-col="${col}"]`);
if (th && typeof px === 'number' && px > 20) {
th.style.width = px + 'px';
}
}
} catch {}
}
function saveQueueColumnWidths() {
try {
const widths = {};
document.querySelectorAll('#queueTable th[data-col]').forEach(th => {
widths[th.dataset.col] = th.getBoundingClientRect().width;
});
localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(widths));
} catch {}
}
function setupColumnResizing() {
const headers = document.querySelectorAll('#queueTable th[data-col]');
headers.forEach(th => {
const resizer = th.querySelector('.col-resizer');
if (!resizer) return;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = th.getBoundingClientRect().width;
resizer.classList.add('dragging');
document.body.classList.add('col-resizing');
const onMove = (ev) => {
const delta = ev.clientX - startX;
const newWidth = Math.max(40, startWidth + delta);
th.style.width = newWidth + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizer.classList.remove('dragging');
document.body.classList.remove('col-resizing');
saveQueueColumnWidths();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
// Single-pass escape instead of 4 chained .replace(/x/g, ...) calls.
// Hot path on large table rebuilds — every text cell runs through one of these.
const _HTML_ESC_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
const _HTML_ESC_RE = /[&<>"]/g;
const _ATTR_ESC_MAP = { '&': '&amp;', '"': '&quot;', "'": '&#39;' };
const _ATTR_ESC_RE = /[&"']/g;
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]);
}
function escapeAttr(str) {
if (!str) return '';
return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]);
}
function showCopyToast(msg, durationMs) {
const toast = document.getElementById('copyToast');
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 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();