- Status bar shows uploaded/total bytes (e.g. "16 GB / 281 GB") Total is sum of all queue jobs (100GB x 4 hosters = 400GB) - Fix semaphore acquisition order: hoster-first then global prevents jobs waiting on a hoster slot from wasting global semaphore slots, significantly increasing active connection utilization - Context menu: dynamic count on all labels, singular/plural for single selection, user-adjusted grouping with separators Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2627 lines
98 KiB
JavaScript
2627 lines
98 KiB
JavaScript
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'];
|
|
|
|
// --- State ---
|
|
let selectedFiles = []; // { path, name, size }
|
|
let selectedUploadHosters = [];
|
|
let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
|
|
let hosterSettings = {};
|
|
let uploading = false;
|
|
let healthCheckRunning = false;
|
|
let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
|
|
let editingAccountHoster = null; // null = adding, string = editing
|
|
let autoHealthCheckEnabled = true;
|
|
let queuePersistTimer = null;
|
|
let settingsSaveTimer = null;
|
|
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
|
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
|
|
|
// Queue state
|
|
let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link }
|
|
let _jobIndexById = new Map(); // id -> job (O(1) lookup)
|
|
let _jobIndexByUploadId = new Map(); // uploadId -> job
|
|
let selectedJobIds = new Set();
|
|
let queueSortState = { key: 'filename', direction: 'asc' };
|
|
|
|
// History state
|
|
let historyRowsData = [];
|
|
let historySortState = { key: 'date', direction: 'desc' };
|
|
|
|
// Session-specific files for the "Files" panel (resets each session)
|
|
let sessionFilesData = [];
|
|
let recentSortState = { key: 'date', direction: 'desc' };
|
|
let selectedRecentIds = new Set();
|
|
|
|
// --- Init ---
|
|
async function init() {
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
autoHealthCheckEnabled = loadAutoCheckPreference();
|
|
ensureAccountStatusEntries();
|
|
syncSelectedUploadHosters();
|
|
restoreQueueStateFromConfig();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
renderSettings();
|
|
renderAccounts();
|
|
setupListeners();
|
|
setupDragDrop();
|
|
loadHistory();
|
|
renderRecentUploadsPanel();
|
|
updateUploadView();
|
|
updateStatusBar();
|
|
|
|
// Version display
|
|
try {
|
|
const version = await window.api.getVersion();
|
|
const versionLabel = document.getElementById('versionLabel');
|
|
if (versionLabel) versionLabel.textContent = `v${version}`;
|
|
} catch {}
|
|
|
|
// Update listeners
|
|
window.api.onUpdateAvailable(showUpdateBanner);
|
|
window.api.onUpdateProgress(handleUpdateProgress);
|
|
|
|
// Upload event listeners — with debug logging to file
|
|
window.api.onUploadProgress((data) => {
|
|
window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || ''));
|
|
handleProgress(data);
|
|
});
|
|
window.api.onUploadBatchDone((data) => {
|
|
window.api.debugLog('RX upload-batch-done');
|
|
handleBatchDone(data);
|
|
});
|
|
window.api.onUploadStats((data) => {
|
|
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
|
|
handleStats(data);
|
|
});
|
|
window.api.onShutdownCountdown(handleShutdownCountdown);
|
|
|
|
window.api.debugLog('init complete, all listeners registered');
|
|
|
|
// Restore always-on-top state
|
|
try {
|
|
const onTop = await window.api.getAlwaysOnTop();
|
|
alwaysOnTopState = !!onTop;
|
|
} catch {}
|
|
|
|
scheduleStartupAccountCheck();
|
|
}
|
|
|
|
// --- Tab switching ---
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(`${tab.dataset.view}-view`).classList.add('active');
|
|
if (tab.dataset.view === 'history') loadHistory();
|
|
});
|
|
});
|
|
|
|
// --- Hoster selection ---
|
|
function hosterHasCredentials(name, hoster) {
|
|
if (name === 'vidmoly.me') return !!(hoster.username && hoster.password);
|
|
if (name === 'voe.sx' || name === 'doodstream.com') return !!(hoster.username && hoster.password) || !!hoster.apiKey;
|
|
return !!hoster.apiKey;
|
|
}
|
|
|
|
function getAvailableHosters() {
|
|
return HOSTERS
|
|
.map(name => {
|
|
const hoster = config.hosters[name] || {};
|
|
return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) };
|
|
})
|
|
.filter(item => item.hasCreds);
|
|
}
|
|
|
|
function syncSelectedUploadHosters() {
|
|
const available = new Set(getAvailableHosters().map(item => item.name));
|
|
selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name));
|
|
if (selectedUploadHosters.length === 0) {
|
|
selectedUploadHosters = HOSTERS.filter(name => {
|
|
const hoster = config.hosters[name] || {};
|
|
return !!hoster.enabled && hosterHasCredentials(name, hoster);
|
|
});
|
|
}
|
|
}
|
|
|
|
function getSelectedHosters() {
|
|
return selectedUploadHosters.slice();
|
|
}
|
|
|
|
function getHosterLabel(name) {
|
|
const labels = {
|
|
'doodstream.com': 'Doodstream',
|
|
'voe.sx': 'VOE',
|
|
'vidmoly.me': 'Vidmoly',
|
|
'byse.sx': 'Byse'
|
|
};
|
|
return labels[name] || name;
|
|
}
|
|
|
|
function getAccountModeParts(name, hoster) {
|
|
if (!hoster) return [];
|
|
const hasLogin = !!(hoster.username && hoster.password);
|
|
const hasApi = !!hoster.apiKey;
|
|
|
|
if (name === 'vidmoly.me') return hasLogin ? ['Login Web'] : [];
|
|
if (name === 'byse.sx') return hasApi ? ['API'] : [];
|
|
|
|
const parts = [];
|
|
if (hasLogin) parts.push('Login Web');
|
|
if (hasApi) parts.push('API');
|
|
return parts;
|
|
}
|
|
|
|
function getAccountDisplayName(name, hoster) {
|
|
const parts = getAccountModeParts(name, hoster);
|
|
return parts.length > 0
|
|
? `${getHosterLabel(name)} (${parts.join(' + ')})`
|
|
: getHosterLabel(name);
|
|
}
|
|
|
|
function maskCredential(value, keep = 4) {
|
|
const text = String(value || '').trim();
|
|
if (!text) return '';
|
|
if (text.length <= keep) return text;
|
|
return `${text.slice(0, keep)}…${text.slice(-2)}`;
|
|
}
|
|
|
|
function ensureAccountStatusEntries() {
|
|
const nextStatuses = {};
|
|
for (const { name } of getAccountsWithCreds()) {
|
|
nextStatuses[name] = accountStatuses[name] || { status: 'unchecked', message: '' };
|
|
}
|
|
accountStatuses = nextStatuses;
|
|
}
|
|
|
|
function scheduleStartupAccountCheck() {
|
|
const accounts = getAccountsWithCreds();
|
|
if (!accounts.length) return;
|
|
setTimeout(() => {
|
|
runHealthCheck('startup').catch(() => {});
|
|
}, 500);
|
|
}
|
|
|
|
function renderHosterSummary() {
|
|
const summary = document.getElementById('hosterSummary');
|
|
if (!summary) return;
|
|
const hosters = getSelectedHosters();
|
|
if (hosters.length === 0) {
|
|
summary.textContent = 'Keine Upload-Ziele ausgewählt';
|
|
} else if (hosters.length === 1) {
|
|
summary.textContent = `Aktives Ziel: ${getAccountDisplayName(hosters[0], config.hosters[hosters[0]] || {})}`;
|
|
} else {
|
|
summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`;
|
|
}
|
|
}
|
|
|
|
function renderHosterModal() {
|
|
const list = document.getElementById('hosterModalList');
|
|
const hint = document.getElementById('hosterModalHint');
|
|
if (!list || !hint) return;
|
|
|
|
const available = getAvailableHosters();
|
|
if (available.length === 0) {
|
|
list.innerHTML = '';
|
|
hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = available.map(item => {
|
|
const checked = selectedUploadHosters.includes(item.name);
|
|
const h = config.hosters[item.name] || {};
|
|
const st = accountStatuses[item.name];
|
|
const subtitle = st && st.status === 'ok' ? 'Bereit'
|
|
: st && st.status === 'warn' ? 'Prüfung mit Warnung'
|
|
: st && st.status === 'error' ? 'Login-Fehler'
|
|
: `${getCredentialLabel(item.name, h)} hinterlegt`;
|
|
return `
|
|
<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(getAccountDisplayName(item.name, h))}</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
|
|
if (_pendingFiles.length > 0) {
|
|
selectedFiles.push(..._pendingFiles);
|
|
_pendingFiles = [];
|
|
}
|
|
renderHosterSummary();
|
|
updateUploadView();
|
|
persistQueueStateSoon(true); // immediate persist after adding files
|
|
document.getElementById('hosterModal').style.display = 'none';
|
|
}
|
|
|
|
function cancelHosterModal() {
|
|
_pendingFiles = [];
|
|
closeHosterModal();
|
|
}
|
|
|
|
function normalizeRestoredJobStatus(status) {
|
|
if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status;
|
|
return 'queued';
|
|
}
|
|
|
|
function restoreQueueStateFromConfig() {
|
|
if (config?.globalSettings?.resumeQueueOnLaunch === false) return;
|
|
const pending = config?.globalSettings?.pendingQueue;
|
|
if (!pending || typeof pending !== 'object') return;
|
|
|
|
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
|
|
? pending.selectedUploadHosters.filter(Boolean)
|
|
: selectedUploadHosters;
|
|
|
|
selectedFiles = Array.isArray(pending.selectedFiles)
|
|
? pending.selectedFiles
|
|
.filter(file => file && file.path)
|
|
.map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 }))
|
|
: [];
|
|
|
|
queueJobs = Array.isArray(pending.queueJobs)
|
|
? pending.queueJobs
|
|
.filter(job => job && job.fileName && job.hoster)
|
|
.map(job => ({
|
|
id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
uploadId: null,
|
|
file: job.file || '',
|
|
fileName: job.fileName,
|
|
hoster: job.hoster,
|
|
status: normalizeRestoredJobStatus(job.status),
|
|
bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
|
|
bytesTotal: job.bytesTotal || 0,
|
|
speedKbs: 0,
|
|
elapsed: 0,
|
|
remaining: 0,
|
|
error: job.error || null,
|
|
result: job.result || null,
|
|
attempt: 0,
|
|
maxAttempts: job.maxAttempts || 0,
|
|
link: '',
|
|
progress: job.status === 'done' ? 1 : 0
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
function buildPersistedQueueState() {
|
|
const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status));
|
|
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
|
|
|
|
for (const job of persistableJobs) {
|
|
if (job.file && !selectedFileMap.has(job.file)) {
|
|
selectedFileMap.set(job.file, {
|
|
path: job.file,
|
|
name: job.fileName,
|
|
size: job.bytesTotal || 0
|
|
});
|
|
}
|
|
}
|
|
|
|
if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
selectedUploadHosters: getSelectedHosters(),
|
|
selectedFiles: Array.from(selectedFileMap.values()),
|
|
queueJobs: queueJobs.map(job => ({
|
|
id: job.id,
|
|
file: job.file,
|
|
fileName: job.fileName,
|
|
hoster: job.hoster,
|
|
// Save aborted jobs as queued so they survive restart
|
|
status: job.status === 'aborted' ? 'queued' : job.status,
|
|
bytesTotal: job.bytesTotal || 0,
|
|
error: job.status === 'aborted' ? null : (job.error || null),
|
|
result: job.result || null,
|
|
maxAttempts: job.maxAttempts || 0
|
|
}))
|
|
};
|
|
}
|
|
|
|
async function persistQueueStateNow() {
|
|
const globalSettings = {
|
|
...(config.globalSettings || {}),
|
|
pendingQueue: buildPersistedQueueState()
|
|
};
|
|
config.globalSettings = globalSettings;
|
|
await window.api.saveGlobalSettings(globalSettings);
|
|
}
|
|
|
|
function persistQueueStateSoon(immediate) {
|
|
clearTimeout(queuePersistTimer);
|
|
if (immediate) {
|
|
persistQueueStateNow().catch(() => {});
|
|
return;
|
|
}
|
|
// Use longer debounce during uploads to reduce disk I/O
|
|
const delay = uploading ? 3000 : 500;
|
|
queuePersistTimer = setTimeout(() => {
|
|
persistQueueStateNow().catch(() => {});
|
|
}, delay);
|
|
}
|
|
|
|
function clearPersistedQueueStateSoon() {
|
|
clearTimeout(queuePersistTimer);
|
|
queuePersistTimer = setTimeout(() => {
|
|
const globalSettings = {
|
|
...(config.globalSettings || {}),
|
|
pendingQueue: null
|
|
};
|
|
config.globalSettings = globalSettings;
|
|
window.api.saveGlobalSettings(globalSettings).catch(() => {});
|
|
}, 0);
|
|
}
|
|
|
|
// --- File selection ---
|
|
function setupDragDrop() {
|
|
const dropZone = document.getElementById('dropZone');
|
|
// Allow drop on the entire upload view
|
|
const uploadView = document.getElementById('upload-view');
|
|
|
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); });
|
|
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
|
|
dropZone.addEventListener('drop', (e) => {
|
|
e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over');
|
|
addDroppedFiles(e.dataTransfer.files);
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
|
|
let _pendingFiles = []; // Files waiting for hoster modal confirmation
|
|
|
|
function addDroppedFiles(fileList) {
|
|
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) {
|
|
// Use webUtils.getPathForFile (Electron 33+) with fallback to file.path
|
|
let filePath = '';
|
|
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
|
|
const fileName = file.name || '';
|
|
if (filePath && !existingPaths.has(filePath)) {
|
|
newFiles.push({ path: filePath, name: fileName, size: file.size });
|
|
existingPaths.add(filePath);
|
|
}
|
|
}
|
|
if (newFiles.length > 0) {
|
|
_pendingFiles.push(...newFiles);
|
|
openHosterModal();
|
|
}
|
|
}
|
|
|
|
async function pickFiles() {
|
|
const paths = await window.api.selectFiles();
|
|
if (!paths) return;
|
|
const newFiles = [];
|
|
for (const p of paths) {
|
|
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) {
|
|
const name = p.split('\\').pop().split('/').pop();
|
|
newFiles.push({ path: p, name, size: null });
|
|
}
|
|
}
|
|
if (newFiles.length > 0) {
|
|
_pendingFiles.push(...newFiles);
|
|
openHosterModal();
|
|
}
|
|
}
|
|
|
|
function updateUploadView() {
|
|
const dropZone = document.getElementById('dropZone');
|
|
const queueShell = document.getElementById('queueShell');
|
|
const queueActions = document.getElementById('queueActions');
|
|
|
|
if (selectedFiles.length === 0 && queueJobs.length === 0) {
|
|
dropZone.style.display = 'flex';
|
|
queueShell.style.display = 'none';
|
|
queueActions.style.display = 'none';
|
|
} else {
|
|
dropZone.style.display = 'none';
|
|
queueShell.style.display = 'flex';
|
|
queueActions.style.display = 'flex';
|
|
if (!uploading && selectedFiles.length > 0) {
|
|
buildQueuePreview();
|
|
}
|
|
}
|
|
updateQueueActionButtons();
|
|
}
|
|
|
|
function updateStartButton() {
|
|
const btn = document.getElementById('startUploadBtn');
|
|
const hosters = getSelectedHosters();
|
|
const hasFiles = queueJobs.some(j => j.status === 'queued' || j.status === 'preview');
|
|
btn.disabled = uploading || hosters.length === 0 || !hasFiles;
|
|
}
|
|
|
|
function updateQueueActionButtons() {
|
|
updateStartButton();
|
|
|
|
const hasSelection = selectedJobIds.size > 0;
|
|
const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status));
|
|
const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status));
|
|
const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued'].includes(job.status));
|
|
const hasMovableSelection = hasSelection && !uploading;
|
|
|
|
const startSelectedBtn = document.getElementById('startSelectedBtn');
|
|
const reuploadBtn = document.getElementById('reuploadSelectedBtn');
|
|
const abortSelectedBtn = document.getElementById('abortSelectedBtn');
|
|
const finishStopBtn = document.getElementById('finishStopBtn');
|
|
const abortAllBtn = document.getElementById('abortAllBtn');
|
|
const moveTopBtn = document.getElementById('moveTopBtn');
|
|
const moveUpBtn = document.getElementById('moveUpBtn');
|
|
const moveDownBtn = document.getElementById('moveDownBtn');
|
|
const moveBottomBtn = document.getElementById('moveBottomBtn');
|
|
|
|
if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection || getSelectedHosters().length === 0;
|
|
if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection;
|
|
if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection;
|
|
if (finishStopBtn) finishStopBtn.disabled = !uploading;
|
|
if (abortAllBtn) abortAllBtn.disabled = !uploading;
|
|
if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection;
|
|
if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection;
|
|
if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection;
|
|
if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection;
|
|
}
|
|
|
|
// Build preview jobs from selected files x selected hosters (before upload starts)
|
|
function buildQueuePreview() {
|
|
const hosters = getSelectedHosters();
|
|
if (hosters.length === 0) {
|
|
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
|
rebuildJobIndex();
|
|
renderQueueTable();
|
|
persistQueueStateSoon();
|
|
return;
|
|
}
|
|
// Remove old preview jobs
|
|
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
|
|
|
// Build a Set for fast existence checks
|
|
const existingKeys = new Set();
|
|
for (const j of queueJobs) {
|
|
if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`);
|
|
}
|
|
|
|
for (const file of selectedFiles) {
|
|
for (const hoster of hosters) {
|
|
const key = `${file.path}|${hoster}`;
|
|
if (!existingKeys.has(key)) {
|
|
const job = {
|
|
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
file: file.path, fileName: file.name, hoster,
|
|
status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0,
|
|
speedKbs: 0, elapsed: 0, remaining: 0,
|
|
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
|
|
};
|
|
queueJobs.push(job);
|
|
existingKeys.add(key);
|
|
}
|
|
}
|
|
}
|
|
rebuildJobIndex();
|
|
renderQueueTable();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
// --- Job Index Management ---
|
|
function rebuildJobIndex() {
|
|
_jobIndexById.clear();
|
|
_jobIndexByUploadId.clear();
|
|
for (const job of queueJobs) {
|
|
_jobIndexById.set(job.id, job);
|
|
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
|
|
}
|
|
}
|
|
|
|
function indexJob(job) {
|
|
_jobIndexById.set(job.id, job);
|
|
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
|
|
}
|
|
|
|
function removeJobFromIndex(job) {
|
|
_jobIndexById.delete(job.id);
|
|
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId);
|
|
}
|
|
|
|
// --- Queue Table Rendering (debounced with virtual scrolling) ---
|
|
let _renderQueued = false;
|
|
let _sortedJobsCache = [];
|
|
const VIRTUAL_ROW_HEIGHT = 28;
|
|
const VIRTUAL_OVERSCAN = 10;
|
|
let _lastVisibleRange = { start: -1, end: -1 };
|
|
let _queueListenersBound = false;
|
|
|
|
function scheduleQueueRender() {
|
|
if (_renderQueued) return;
|
|
_renderQueued = true;
|
|
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
|
|
}
|
|
|
|
function buildRowHtml(job) {
|
|
const statusClass = `status-${job.status}`;
|
|
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
|
|
const uploadedSize = job.status === 'preview'
|
|
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
|
|
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
|
|
const statusText = getStatusText(job);
|
|
const elapsed = formatTime(job.elapsed);
|
|
const remaining = formatTime(job.remaining);
|
|
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
|
|
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
|
|
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
|
|
|
|
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
|
|
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
|
|
<td class="col-size">${uploadedSize}</td>
|
|
<td class="col-host">${escapeHtml(job.hoster)}</td>
|
|
<td class="col-status"><span class="status-badge ${statusClass}">${statusText}</span></td>
|
|
<td class="col-elapsed">${elapsed}</td>
|
|
<td class="col-remaining">${remaining}</td>
|
|
<td class="col-speed">${speed}</td>
|
|
<td class="col-progress">
|
|
<div class="progress-cell">
|
|
<div class="progress-bar-bg">
|
|
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function renderQueueTable() {
|
|
const tbody = document.getElementById('queueBody');
|
|
if (!tbody) return;
|
|
|
|
_sortedJobsCache = sortQueueJobs(queueJobs);
|
|
const totalRows = _sortedJobsCache.length;
|
|
|
|
// For small queues (<200 rows), use simple rendering
|
|
if (totalRows < 200) {
|
|
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
|
|
_lastVisibleRange = { start: -1, end: -1 };
|
|
} else {
|
|
// Virtual scrolling for large queues — force re-render
|
|
_lastVisibleRange = { start: -1, end: -1 };
|
|
_renderVirtualRows(tbody);
|
|
}
|
|
|
|
// Bind event delegation once
|
|
if (!_queueListenersBound) {
|
|
_queueListenersBound = true;
|
|
tbody.addEventListener('click', (e) => {
|
|
const row = e.target.closest('.queue-row');
|
|
if (row) handleRowClick(e, row);
|
|
});
|
|
tbody.addEventListener('contextmenu', (e) => {
|
|
const row = e.target.closest('.queue-row');
|
|
if (row) handleRowContextMenu(e, row);
|
|
});
|
|
}
|
|
|
|
// Update retry button visibility
|
|
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
|
|
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
|
|
updateQueueActionButtons();
|
|
}
|
|
|
|
function _renderVirtualRows(tbody) {
|
|
const scrollContainer = document.getElementById('queueContainer');
|
|
if (!scrollContainer) return;
|
|
|
|
const totalRows = _sortedJobsCache.length;
|
|
const scrollTop = scrollContainer.scrollTop;
|
|
// Use a minimum viewport height to avoid rendering nothing when container is being laid out
|
|
const viewportHeight = Math.max(scrollContainer.clientHeight, 200);
|
|
|
|
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
|
const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
|
|
|
|
// Only re-render if visible range changed
|
|
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) return;
|
|
_lastVisibleRange = { start: startIdx, end: endIdx };
|
|
|
|
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
|
|
const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT);
|
|
|
|
let html = '';
|
|
if (topPad > 0) html += `<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="8"></td></tr>`;
|
|
for (let i = startIdx; i < endIdx; i++) {
|
|
html += buildRowHtml(_sortedJobsCache[i]);
|
|
}
|
|
if (bottomPad > 0) html += `<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="8"></td></tr>`;
|
|
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
function _onQueueScroll() {
|
|
if (_sortedJobsCache.length >= 200) {
|
|
const tbody = document.getElementById('queueBody');
|
|
if (tbody) _renderVirtualRows(tbody);
|
|
}
|
|
}
|
|
|
|
function sortQueueJobs(jobs) {
|
|
const { key, direction } = queueSortState;
|
|
const factor = direction === 'asc' ? 1 : -1;
|
|
|
|
return jobs.slice().sort((a, b) => {
|
|
let cmp = 0;
|
|
if (key === 'filename') cmp = a.fileName.localeCompare(b.fileName, 'de', { sensitivity: 'base', numeric: true });
|
|
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
|
|
else if (key === 'host') cmp = a.hoster.localeCompare(b.hoster);
|
|
else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status);
|
|
else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0);
|
|
else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0);
|
|
return cmp * factor;
|
|
});
|
|
}
|
|
|
|
function getStatusOrder(status) {
|
|
const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 };
|
|
return order[status] ?? 4;
|
|
}
|
|
|
|
function getStatusText(job) {
|
|
switch (job.status) {
|
|
case 'preview': return 'Bereit';
|
|
case 'queued': return 'Wartet';
|
|
case 'getting-server': return 'Server...';
|
|
case 'uploading': return 'Upload';
|
|
case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`;
|
|
case 'done': return 'Fertig';
|
|
case 'aborted': return 'Abgebrochen';
|
|
case 'error': return 'Fehlgeschlagen';
|
|
case 'skipped': return 'Übersprungen';
|
|
default: return job.status;
|
|
}
|
|
}
|
|
|
|
// --- Queue interactions ---
|
|
function handleRowClick(e, row) {
|
|
const jobId = row.dataset.jobId;
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
|
|
else selectedJobIds.add(jobId);
|
|
} else if (e.shiftKey && selectedJobIds.size > 0) {
|
|
// Use sorted jobs cache for correct shift-click with virtual scrolling
|
|
const sortedIds = _sortedJobsCache.map(j => j.id);
|
|
const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id));
|
|
const curIdx = sortedIds.indexOf(jobId);
|
|
if (lastIdx >= 0 && curIdx >= 0) {
|
|
const from = Math.min(lastIdx, curIdx);
|
|
const to = Math.max(lastIdx, curIdx);
|
|
for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]);
|
|
}
|
|
} else {
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add(jobId);
|
|
// Single click on done job -> copy link
|
|
const job = queueJobs.find(j => j.id === jobId);
|
|
if (job && job.status === 'done' && job.result) {
|
|
const link = job.result.download_url || job.result.embed_url || '';
|
|
if (link) {
|
|
window.api.copyToClipboard(link);
|
|
showCopyToast('Link kopiert');
|
|
}
|
|
}
|
|
}
|
|
renderQueueTable();
|
|
}
|
|
|
|
// --- Context menu ---
|
|
let alwaysOnTopState = false;
|
|
|
|
function handleRowContextMenu(e, row) {
|
|
e.preventDefault();
|
|
const jobId = row.dataset.jobId;
|
|
if (!selectedJobIds.has(jobId)) {
|
|
selectedJobIds.clear();
|
|
selectedJobIds.add(jobId);
|
|
renderQueueTable();
|
|
}
|
|
showContextMenu(e.clientX, e.clientY);
|
|
}
|
|
|
|
function showContextMenu(x, y) {
|
|
const menu = document.getElementById('contextMenu');
|
|
// Update "Always on top" text
|
|
const aotItem = menu.querySelector('[data-action="always-on-top"]');
|
|
if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund';
|
|
// Update labels with selection count
|
|
const n = selectedJobIds.size;
|
|
const delItem = menu.querySelector('[data-action="delete-selected"]');
|
|
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
|
|
const copyItem = menu.querySelector('[data-action="copy-links"]');
|
|
if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren';
|
|
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
|
|
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
|
|
});
|
|
const startItem = menu.querySelector('[data-action="start-selected"]');
|
|
if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten';
|
|
|
|
menu.style.display = 'block';
|
|
const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5);
|
|
menu.style.left = menuX + 'px';
|
|
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
|
|
|
|
// Flip submenus if they would overflow the viewport right edge
|
|
menu.querySelectorAll('.ctx-submenu-items').forEach(sub => {
|
|
sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth);
|
|
});
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
document.getElementById('contextMenu').style.display = 'none';
|
|
document.getElementById('recentContextMenu').style.display = 'none';
|
|
}
|
|
|
|
function deleteSelectedRecentFiles() {
|
|
if (selectedRecentIds.size === 0) return;
|
|
sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order));
|
|
selectedRecentIds.clear();
|
|
renderRecentUploadsPanel();
|
|
}
|
|
|
|
function copySelectedRecentLinks() {
|
|
const links = sessionFilesData
|
|
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
|
|
.map(r => r.link)
|
|
.filter(Boolean);
|
|
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
|
}
|
|
|
|
// --- Backup modal ---
|
|
let _backupMode = null; // 'export' | 'import'
|
|
|
|
function openBackupModal(mode) {
|
|
_backupMode = mode;
|
|
const modal = document.getElementById('backupPasswordModal');
|
|
const title = document.getElementById('backupModalTitle');
|
|
const confirmRow = document.getElementById('backupConfirmRow');
|
|
const status = document.getElementById('backupModalStatus');
|
|
document.getElementById('backupPassword').value = '';
|
|
document.getElementById('backupPasswordConfirm').value = '';
|
|
status.textContent = '';
|
|
if (mode === 'export') {
|
|
title.textContent = 'Backup verschlüsseln';
|
|
confirmRow.style.display = '';
|
|
document.getElementById('confirmBackupBtn').textContent = 'Exportieren';
|
|
} else {
|
|
title.textContent = 'Backup entschlüsseln';
|
|
confirmRow.style.display = 'none';
|
|
document.getElementById('confirmBackupBtn').textContent = 'Importieren';
|
|
}
|
|
modal.style.display = 'flex';
|
|
document.getElementById('backupPassword').focus();
|
|
}
|
|
|
|
function closeBackupModal() {
|
|
document.getElementById('backupPasswordModal').style.display = 'none';
|
|
_backupMode = null;
|
|
}
|
|
|
|
async function confirmBackupAction() {
|
|
const pw = document.getElementById('backupPassword').value;
|
|
const status = document.getElementById('backupModalStatus');
|
|
if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; }
|
|
|
|
if (_backupMode === 'export') {
|
|
const pw2 = document.getElementById('backupPasswordConfirm').value;
|
|
if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; }
|
|
status.textContent = 'Exportiere...';
|
|
try {
|
|
const result = await window.api.exportBackup(pw);
|
|
if (result.canceled) { status.textContent = ''; return; }
|
|
if (result.ok) {
|
|
closeBackupModal();
|
|
showCopyToast('Backup exportiert');
|
|
}
|
|
} catch (err) {
|
|
status.textContent = err.message || 'Export fehlgeschlagen';
|
|
}
|
|
} else {
|
|
status.textContent = 'Importiere...';
|
|
try {
|
|
const result = await window.api.importBackup(pw);
|
|
if (result.canceled) { status.textContent = ''; return; }
|
|
if (result.ok) {
|
|
config = result.config;
|
|
hosterSettings = config.hosterSettings || {};
|
|
// Refresh global settings state (always-on-top, etc.)
|
|
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
|
window.api.setAlwaysOnTop(alwaysOnTopState);
|
|
closeBackupModal();
|
|
renderSettings();
|
|
renderAccounts();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
loadHistory();
|
|
showCopyToast('Backup importiert');
|
|
}
|
|
} catch (err) {
|
|
status.textContent = err.message || 'Import fehlgeschlagen';
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.context-menu')) hideContextMenu();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
hideContextMenu();
|
|
cancelHosterModal();
|
|
}
|
|
if (e.target.closest('input, textarea, select')) return;
|
|
const activeView = document.querySelector('.view.active');
|
|
// Ctrl+A
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
if (activeView && activeView.id === 'upload-view') {
|
|
e.preventDefault();
|
|
// If recent files panel is focused / has selection, select all recent files
|
|
if (selectedRecentIds.size > 0 || document.activeElement?.closest('.recent-files-panel')) {
|
|
sessionFilesData.forEach(r => selectedRecentIds.add(r.order));
|
|
renderRecentUploadsPanel();
|
|
} else if (queueJobs.length > 0) {
|
|
queueJobs.forEach(j => selectedJobIds.add(j.id));
|
|
renderQueueTable();
|
|
}
|
|
}
|
|
}
|
|
// Delete
|
|
if (e.key === 'Delete') {
|
|
if (activeView && activeView.id === 'upload-view') {
|
|
e.preventDefault();
|
|
if (selectedRecentIds.size > 0) {
|
|
deleteSelectedRecentFiles();
|
|
} else if (selectedJobIds.size > 0 && !uploading) {
|
|
queueJobs = queueJobs.filter(j => {
|
|
if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; }
|
|
return true;
|
|
});
|
|
selectedJobIds.clear();
|
|
syncSelectedFilesFromQueue();
|
|
renderQueueTable();
|
|
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
|
updateStatusBar();
|
|
persistQueueStateSoon();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('contextMenu').addEventListener('click', (e) => {
|
|
const item = e.target.closest('.ctx-item');
|
|
if (!item) return;
|
|
const action = item.dataset.action;
|
|
if (!action) return;
|
|
hideContextMenu();
|
|
handleContextAction(action);
|
|
});
|
|
|
|
async function handleContextAction(action) {
|
|
if (action === 'start-selected') {
|
|
startSelectedUpload();
|
|
} else if (action === 'copy-links') {
|
|
const links = getSelectedJobLinks();
|
|
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
|
} else if (action === 'retry-selected') {
|
|
retrySelectedJobs();
|
|
} else if (action === 'delete-selected') {
|
|
queueJobs = queueJobs.filter(j => {
|
|
if (selectedJobIds.has(j.id)) {
|
|
removeJobFromIndex(j);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
selectedJobIds.clear();
|
|
syncSelectedFilesFromQueue();
|
|
renderQueueTable();
|
|
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
|
updateStatusBar();
|
|
persistQueueStateSoon();
|
|
} else if (action === 'copy-all-links') {
|
|
copyAllLinks();
|
|
} else if (action === 'delete-all') {
|
|
queueJobs.forEach(j => removeJobFromIndex(j));
|
|
queueJobs = [];
|
|
selectedJobIds.clear();
|
|
selectedFiles = [];
|
|
syncSelectedFilesFromQueue();
|
|
renderQueueTable();
|
|
updateUploadView();
|
|
updateStatusBar();
|
|
persistQueueStateSoon();
|
|
} else if (action === 'always-on-top') {
|
|
alwaysOnTopState = !alwaysOnTopState;
|
|
await window.api.setAlwaysOnTop(alwaysOnTopState);
|
|
} else if (action.startsWith('shutdown-')) {
|
|
const mode = action.replace('shutdown-', '');
|
|
await window.api.setShutdownAfterFinish(mode);
|
|
}
|
|
}
|
|
|
|
function getSelectedJobLinks() {
|
|
return queueJobs
|
|
.filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result)
|
|
.map(j => j.result.download_url || j.result.embed_url || '')
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// --- Upload ---
|
|
async function startUpload() {
|
|
if (healthCheckRunning || uploading) return;
|
|
|
|
const hosters = getSelectedHosters();
|
|
if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; }
|
|
if (queueJobs.length === 0 && selectedFiles.length > 0) buildQueuePreview();
|
|
|
|
const jobsToStart = queueJobs.filter((job) => job.status === 'preview' || job.status === 'queued');
|
|
if (jobsToStart.length === 0) return;
|
|
|
|
// Auto health check
|
|
if (autoHealthCheckEnabled) {
|
|
const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx');
|
|
if (checkHosters.length > 0) {
|
|
healthCheckRunning = true;
|
|
try {
|
|
const rows = await executeHealthCheck(checkHosters, 'auto');
|
|
const errors = rows.filter(r => r.status === 'error');
|
|
if (errors.length > 0) {
|
|
alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`);
|
|
return;
|
|
} finally {
|
|
healthCheckRunning = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
uploading = true;
|
|
queueJobs.forEach(j => {
|
|
if (j.status === 'preview') j.status = 'queued';
|
|
});
|
|
updateQueueActionButtons();
|
|
renderQueueTable();
|
|
updateStatusBar();
|
|
|
|
const uploadPayload = {
|
|
hosters,
|
|
jobs: jobsToStart.map((job) => ({
|
|
id: job.id,
|
|
file: job.file,
|
|
fileName: job.fileName,
|
|
hoster: job.hoster
|
|
}))
|
|
};
|
|
const result = await window.api.startUpload(uploadPayload);
|
|
persistQueueStateSoon();
|
|
|
|
if (result && result.error) {
|
|
alert(result.error);
|
|
uploading = false;
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
}
|
|
}
|
|
|
|
async function startSelectedUpload() {
|
|
if (healthCheckRunning || uploading) return;
|
|
|
|
const hosters = getSelectedHosters();
|
|
if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswählen.'); return; }
|
|
|
|
const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && (job.status === 'preview' || job.status === 'queued'));
|
|
if (jobsToStart.length === 0) return;
|
|
|
|
// Auto health check
|
|
if (autoHealthCheckEnabled) {
|
|
const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me' || name === 'voe.sx' || name === 'byse.sx');
|
|
if (checkHosters.length > 0) {
|
|
healthCheckRunning = true;
|
|
try {
|
|
const rows = await executeHealthCheck(checkHosters, 'auto');
|
|
const errors = rows.filter(r => r.status === 'error');
|
|
if (errors.length > 0) {
|
|
alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`);
|
|
return;
|
|
} finally {
|
|
healthCheckRunning = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
uploading = true;
|
|
jobsToStart.forEach(j => {
|
|
if (j.status === 'preview') j.status = 'queued';
|
|
});
|
|
updateQueueActionButtons();
|
|
renderQueueTable();
|
|
updateStatusBar();
|
|
|
|
const uploadPayload = {
|
|
hosters,
|
|
jobs: jobsToStart.map((job) => ({
|
|
id: job.id,
|
|
file: job.file,
|
|
fileName: job.fileName,
|
|
hoster: job.hoster
|
|
}))
|
|
};
|
|
const result = await window.api.startUpload(uploadPayload);
|
|
persistQueueStateSoon();
|
|
|
|
if (result && result.error) {
|
|
alert(result.error);
|
|
uploading = false;
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
}
|
|
}
|
|
|
|
async function cancelUpload() {
|
|
await window.api.cancelUpload();
|
|
uploading = false;
|
|
// Reset all non-finished jobs back to queued state
|
|
for (const job of queueJobs) {
|
|
if (!['done', 'error', 'skipped'].includes(job.status)) {
|
|
job.status = 'queued';
|
|
job.progress = 0;
|
|
job.bytesUploaded = 0;
|
|
job.speedKbs = 0;
|
|
job.elapsed = 0;
|
|
job.remaining = 0;
|
|
job.error = null;
|
|
}
|
|
}
|
|
renderQueueTable();
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
// --- Progress handling ---
|
|
function handleProgress(data) {
|
|
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
|
|
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
|
|
if (!job) {
|
|
job = queueJobs.find(j =>
|
|
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
|
|
) || queueJobs.find(j =>
|
|
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
|
|
);
|
|
if (job && data.uploadId) {
|
|
job.uploadId = data.uploadId;
|
|
_jobIndexByUploadId.set(data.uploadId, job);
|
|
}
|
|
}
|
|
if (!job) {
|
|
job = {
|
|
id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
uploadId: data.uploadId,
|
|
file: '', fileName: data.fileName, hoster: data.hoster,
|
|
status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0,
|
|
speedKbs: 0, elapsed: 0, remaining: 0,
|
|
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
|
|
};
|
|
queueJobs.push(job);
|
|
indexJob(job);
|
|
}
|
|
|
|
// Update job state
|
|
job.status = data.status;
|
|
job.bytesUploaded = data.bytesUploaded || 0;
|
|
job.bytesTotal = data.bytesTotal || job.bytesTotal;
|
|
job.speedKbs = data.speedKbs || 0;
|
|
job.elapsed = data.elapsed || 0;
|
|
job.remaining = data.remaining || 0;
|
|
job.error = data.error || null;
|
|
job.result = data.result || job.result;
|
|
job.attempt = data.attempt || 0;
|
|
job.maxAttempts = data.maxAttempts || 0;
|
|
job.progress = data.progress || 0;
|
|
if (data.uploadId) {
|
|
job.uploadId = data.uploadId;
|
|
_jobIndexByUploadId.set(data.uploadId, job);
|
|
}
|
|
|
|
maybeAddSessionFile(job);
|
|
|
|
// Remove finished jobs from queue immediately if setting is enabled
|
|
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
|
|
removeJobFromIndex(job);
|
|
selectedJobIds.delete(job.id);
|
|
queueJobs = queueJobs.filter(j => j !== job);
|
|
}
|
|
|
|
scheduleQueueRender();
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
updateStatsPanel();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
function handleBatchDone(summary) {
|
|
uploading = false;
|
|
applySummaryResults(summary);
|
|
|
|
// Reset aborted jobs back to queued so they can be restarted
|
|
for (const job of queueJobs) {
|
|
if (job.status === 'aborted') {
|
|
job.status = 'queued';
|
|
job.progress = 0;
|
|
job.bytesUploaded = 0;
|
|
job.speedKbs = 0;
|
|
job.elapsed = 0;
|
|
job.remaining = 0;
|
|
job.error = null;
|
|
}
|
|
}
|
|
|
|
syncSelectedFilesFromQueue();
|
|
updateQueueActionButtons();
|
|
renderQueueTable();
|
|
renderRecentUploadsPanel();
|
|
loadHistory();
|
|
|
|
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
|
|
if (removeOnDone) {
|
|
const doneJobs = queueJobs.filter(j => j.status === 'done');
|
|
for (const job of doneJobs) {
|
|
removeJobFromIndex(job);
|
|
selectedJobIds.delete(job.id);
|
|
}
|
|
queueJobs = queueJobs.filter(j => j.status !== 'done');
|
|
renderQueueTable();
|
|
}
|
|
|
|
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
|
|
else clearPersistedQueueStateSoon();
|
|
|
|
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
|
|
updateStatusBar();
|
|
}
|
|
|
|
function handleStats(data) {
|
|
lastUploadStats = {
|
|
state: data.state || 'idle',
|
|
globalSpeedKbs: data.globalSpeedKbs || 0,
|
|
totalBytes: data.totalBytes || 0,
|
|
elapsed: data.elapsed || 0,
|
|
activeJobs: data.activeJobs || 0
|
|
};
|
|
updateStatusBar();
|
|
updateStatsPanel();
|
|
|
|
// Track run time
|
|
if (data.state === 'uploading' || data.state === 'stopping') {
|
|
if (!statsStartTime) {
|
|
statsStartTime = Date.now();
|
|
statsRunTimer = setInterval(() => {
|
|
const el = document.getElementById('statRunTime');
|
|
if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000));
|
|
}, 1000);
|
|
}
|
|
} else if (data.state === 'idle' && statsRunTimer) {
|
|
clearInterval(statsRunTimer);
|
|
statsRunTimer = null;
|
|
}
|
|
}
|
|
|
|
// --- Retry ---
|
|
async function retrySelectedJobs() {
|
|
const retryJobs = [];
|
|
queueJobs.forEach(j => {
|
|
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
|
|
j.status = 'preview';
|
|
j.error = null;
|
|
j.result = null;
|
|
j.bytesUploaded = 0;
|
|
j.speedKbs = 0;
|
|
j.elapsed = 0;
|
|
j.remaining = 0;
|
|
j.progress = 0;
|
|
j.uploadId = null;
|
|
retryJobs.push(j);
|
|
if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) {
|
|
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
|
|
}
|
|
}
|
|
});
|
|
if (retryJobs.length === 0) return;
|
|
|
|
// Select the retry jobs and start them immediately
|
|
selectedJobIds.clear();
|
|
retryJobs.forEach(j => selectedJobIds.add(j.id));
|
|
renderQueueTable();
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
persistQueueStateSoon();
|
|
await startSelectedUpload();
|
|
}
|
|
|
|
async function abortSelectedJobs() {
|
|
const activeJobIds = [];
|
|
|
|
queueJobs.forEach((job) => {
|
|
if (!selectedJobIds.has(job.id)) return;
|
|
|
|
if (['preview', 'queued'].includes(job.status)) {
|
|
job.status = 'aborted';
|
|
job.error = 'Abgebrochen';
|
|
job.progress = 0;
|
|
job.uploadId = null;
|
|
} else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) {
|
|
activeJobIds.push(job.id);
|
|
}
|
|
});
|
|
|
|
if (activeJobIds.length > 0) {
|
|
await window.api.cancelSelectedJobs(activeJobIds);
|
|
}
|
|
|
|
selectedJobIds.clear();
|
|
syncSelectedFilesFromQueue();
|
|
renderQueueTable();
|
|
updateQueueActionButtons();
|
|
updateStatusBar();
|
|
persistQueueStateSoon(true);
|
|
}
|
|
|
|
async function finishUploadsInProgress() {
|
|
if (!uploading) return;
|
|
await window.api.finishAfterActive();
|
|
lastUploadStats.state = 'stopping';
|
|
updateStatusBar();
|
|
}
|
|
|
|
async function abortAllUploads() {
|
|
await cancelUpload();
|
|
}
|
|
|
|
function moveSelectedJobs(direction) {
|
|
if (uploading || selectedJobIds.size === 0) return;
|
|
|
|
const jobs = queueJobs.slice();
|
|
|
|
if (direction === 'top') {
|
|
queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id)));
|
|
} else if (direction === 'bottom') {
|
|
queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id)));
|
|
} else if (direction === 'up') {
|
|
for (let i = 1; i < jobs.length; i++) {
|
|
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) {
|
|
[jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]];
|
|
}
|
|
}
|
|
queueJobs = jobs;
|
|
} else if (direction === 'down') {
|
|
for (let i = jobs.length - 2; i >= 0; i--) {
|
|
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) {
|
|
[jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]];
|
|
}
|
|
}
|
|
queueJobs = jobs;
|
|
}
|
|
|
|
rebuildJobIndex();
|
|
renderQueueTable();
|
|
updateStatusBar();
|
|
persistQueueStateSoon(true);
|
|
}
|
|
|
|
function syncSelectedFilesFromQueue() {
|
|
const fileMap = new Map();
|
|
queueJobs
|
|
.filter((job) => !['done', 'skipped', 'aborted'].includes(job.status))
|
|
.forEach((job) => {
|
|
if (!job.file || fileMap.has(job.file)) return;
|
|
fileMap.set(job.file, {
|
|
path: job.file,
|
|
name: job.fileName,
|
|
size: job.bytesTotal || 0
|
|
});
|
|
});
|
|
selectedFiles = Array.from(fileMap.values());
|
|
}
|
|
|
|
function maybeAddSessionFile(job) {
|
|
if (!job) return;
|
|
|
|
const dt = formatDateTime(new Date());
|
|
if (job.status === 'done' && job.result) {
|
|
const link = job.result.download_url || job.result.embed_url || '';
|
|
if (!link) return;
|
|
if (!sessionFilesData.some((row) => row.link === link && row.filename === job.fileName && row.host === job.hoster)) {
|
|
sessionFilesData.push({
|
|
date: dt.text,
|
|
dateTs: dt.ts,
|
|
filename: job.fileName || '',
|
|
host: job.hoster || '',
|
|
link,
|
|
isError: false,
|
|
order: sessionFilesData.length
|
|
});
|
|
renderRecentUploadsPanel();
|
|
}
|
|
}
|
|
|
|
if (job.status === 'error') {
|
|
const errorText = `[Fehler] ${job.error || ''}`;
|
|
if (!sessionFilesData.some((row) => row.isError && row.filename === job.fileName && row.host === job.hoster && row.link === errorText)) {
|
|
sessionFilesData.push({
|
|
date: dt.text,
|
|
dateTs: dt.ts,
|
|
filename: job.fileName || '',
|
|
host: job.hoster || '',
|
|
link: errorText,
|
|
isError: true,
|
|
order: sessionFilesData.length
|
|
});
|
|
renderRecentUploadsPanel();
|
|
}
|
|
}
|
|
}
|
|
|
|
function applySummaryResults(summary) {
|
|
const files = Array.isArray(summary?.files) ? summary.files : [];
|
|
for (const file of files) {
|
|
for (const result of file.results || []) {
|
|
const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster);
|
|
if (!job) continue;
|
|
if (result.status === 'done') {
|
|
job.status = 'done';
|
|
job.result = {
|
|
download_url: result.download_url || null,
|
|
embed_url: result.embed_url || null,
|
|
file_code: result.file_code || null
|
|
};
|
|
job.error = null;
|
|
job.progress = 1;
|
|
job.bytesUploaded = job.bytesTotal || file.size || 0;
|
|
} else if (result.status === 'aborted') {
|
|
job.status = 'aborted';
|
|
job.error = result.error || 'Abgebrochen';
|
|
} else if (result.status === 'error') {
|
|
job.status = 'error';
|
|
job.error = result.error || 'Fehlgeschlagen';
|
|
}
|
|
maybeAddSessionFile(job);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateStatusBar() {
|
|
const counts = {
|
|
total: queueJobs.length,
|
|
remaining: queueJobs.filter((job) => ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)).length,
|
|
inProgress: queueJobs.filter((job) => ['getting-server', 'uploading', 'retrying'].includes(job.status)).length,
|
|
error: queueJobs.filter((job) => job.status === 'error').length
|
|
};
|
|
|
|
const bytesRemaining = queueJobs
|
|
.filter((job) => ['getting-server', 'uploading', 'retrying', 'queued', 'preview'].includes(job.status))
|
|
.reduce((sum, job) => sum + Math.max(0, (job.bytesTotal || 0) - (job.bytesUploaded || 0)), 0);
|
|
const etaSeconds = lastUploadStats.globalSpeedKbs > 0
|
|
? Math.round(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 queueTotalBytes = queueJobs.reduce((sum, j) => sum + (j.bytesTotal || 0), 0);
|
|
document.getElementById('sbTotal').textContent = `${formatSize(lastUploadStats.totalBytes || 0)} / ${formatSize(queueTotalBytes)}`;
|
|
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
|
|
document.getElementById('sbConnections').textContent = `Aktive Verbindungen ${lastUploadStats.activeJobs || 0}`;
|
|
document.getElementById('sbQueueCount').textContent = `Gesamt ${counts.total}`;
|
|
document.getElementById('sbRemainingCount').textContent = `Remaining ${counts.remaining}`;
|
|
document.getElementById('sbInProgressCount').textContent = `In Progress ${counts.inProgress}`;
|
|
document.getElementById('sbErrorCount').textContent = `Error ${counts.error}`;
|
|
}
|
|
|
|
// --- Health Check ---
|
|
function setHealthCheckStatus(text) {
|
|
// Minimal inline status
|
|
}
|
|
|
|
function renderHealthCheckResults(results) {
|
|
const container = document.getElementById('healthCheckResults');
|
|
if (!container) return;
|
|
if (!results || results.length === 0) { container.innerHTML = ''; return; }
|
|
|
|
container.innerHTML = results.map(item => {
|
|
const status = item.status || 'skipped';
|
|
return `<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 || !row.hoster) return;
|
|
accountStatuses[row.hoster] = {
|
|
status: row.status || 'unchecked',
|
|
message: row.message || ''
|
|
};
|
|
});
|
|
renderHealthCheckResults(rows);
|
|
renderAccounts();
|
|
renderHosterModal();
|
|
return rows;
|
|
}
|
|
|
|
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
|
if (healthCheckRunning || (uploading && mode === 'manual')) return [];
|
|
const hosters = Array.isArray(requestedHosters) && requestedHosters.length > 0
|
|
? requestedHosters
|
|
: HOSTERS.filter((name) => hosterHasCredentials(name, config.hosters[name] || {}));
|
|
if (hosters.length === 0) {
|
|
if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.');
|
|
return [];
|
|
}
|
|
healthCheckRunning = true;
|
|
hosters.forEach((hoster) => {
|
|
accountStatuses[hoster] = { status: 'checking', message: '' };
|
|
});
|
|
renderAccounts();
|
|
try {
|
|
return await executeHealthCheck(hosters, mode);
|
|
} catch (err) {
|
|
renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]);
|
|
return [];
|
|
} finally {
|
|
healthCheckRunning = false;
|
|
renderAccounts();
|
|
}
|
|
}
|
|
|
|
// --- Settings ---
|
|
function renderSettings() {
|
|
const container = document.getElementById('settingsHosters');
|
|
container.innerHTML = '';
|
|
|
|
const globalSettings = config.globalSettings || {};
|
|
const configuredAccounts = getAccountsWithCreds();
|
|
const generalPanel = document.createElement('div');
|
|
generalPanel.className = 'hoster-settings-panel';
|
|
generalPanel.innerHTML = `
|
|
<div class="hoster-panel-header" data-hoster="global">
|
|
<span class="panel-arrow">▼</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-grid-mini">
|
|
<div class="settings-row">
|
|
<label>Globale parallele Uploads</label>
|
|
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100">
|
|
<span class="hint">0 = nur pro Hoster</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>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">
|
|
<span class="hint">0 = unbegrenzt</span>
|
|
</div>
|
|
</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>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(generalPanel);
|
|
|
|
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, hoster } of configuredAccounts) {
|
|
const hs = hosterSettings[name] || {};
|
|
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0';
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'hoster-settings-panel';
|
|
|
|
panel.innerHTML = `
|
|
<div class="hoster-panel-header" data-hoster="${name}">
|
|
<span class="panel-arrow">▶</span>
|
|
<span class="panel-title">${escapeHtml(getAccountDisplayName(name, hoster))}</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 ? '▶' : '▼';
|
|
});
|
|
}
|
|
|
|
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
|
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(),
|
|
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
|
|
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
|
|
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
|
|
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked,
|
|
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024))
|
|
};
|
|
|
|
// Always on top setting
|
|
const aotCheckbox = document.getElementById('alwaysOnTopInput');
|
|
if (aotCheckbox) {
|
|
const newAot = !!aotCheckbox.checked;
|
|
if (newAot !== alwaysOnTopState) {
|
|
alwaysOnTopState = newAot;
|
|
await window.api.setAlwaysOnTop(alwaysOnTopState);
|
|
}
|
|
}
|
|
|
|
for (const name of HOSTERS) {
|
|
const hs = { ...(hosterSettings[name] || {}) };
|
|
document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => {
|
|
const field = input.dataset.hs;
|
|
if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024));
|
|
else hs[field] = parseInt(input.value, 10) || 0;
|
|
});
|
|
newHosterSettings[name] = hs;
|
|
}
|
|
|
|
await window.api.saveHosterSettings(newHosterSettings);
|
|
await window.api.saveGlobalSettings(globalSettings);
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
clearTimeout(settingsSaveTimer);
|
|
|
|
const feedback = document.getElementById('saveFeedback');
|
|
feedback.textContent = feedbackText;
|
|
setTimeout(() => {
|
|
if (feedback.textContent === feedbackText) {
|
|
feedback.textContent = 'Änderungen werden automatisch gespeichert.';
|
|
}
|
|
}, 1800);
|
|
}
|
|
|
|
// --- Accounts ---
|
|
function getAccountsWithCreds() {
|
|
return HOSTERS
|
|
.map(name => ({ name, hoster: config.hosters[name] || {} }))
|
|
.filter(item => hosterHasCredentials(item.name, item.hoster));
|
|
}
|
|
|
|
function getHostersWithoutCreds() {
|
|
return HOSTERS.filter(name => !hosterHasCredentials(name, config.hosters[name] || {}));
|
|
}
|
|
|
|
function getCredentialLabel(name, hoster) {
|
|
if (name === 'vidmoly.me') return `Login: ${hoster.username || 'nicht gesetzt'}`;
|
|
if (name === 'voe.sx' || name === 'doodstream.com') {
|
|
const parts = [];
|
|
if (hoster.username && hoster.password) parts.push(`Login: ${hoster.username}`);
|
|
if (hoster.apiKey) parts.push(`API: ${maskCredential(hoster.apiKey)}`);
|
|
return parts.join(' • ') || 'Keine Zugangsdaten';
|
|
}
|
|
return `API: ${maskCredential(hoster.apiKey) || 'nicht gesetzt'}`;
|
|
}
|
|
|
|
function renderAccounts() {
|
|
const container = document.getElementById('accountsList');
|
|
if (!container) return;
|
|
ensureAccountStatusEntries();
|
|
|
|
const accounts = getAccountsWithCreds();
|
|
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
|
|
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
|
|
|
|
if (accounts.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="accounts-empty">
|
|
<p>Keine Accounts vorhanden</p>
|
|
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = accounts.map(({ name, hoster }) => {
|
|
const st = accountStatuses[name] || { status: 'unchecked', message: '' };
|
|
const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
|
|
const statusLabel = statusLabels[st.status] || 'Nicht geprüft';
|
|
const credLabel = getCredentialLabel(name, hoster);
|
|
|
|
return `
|
|
<div class="account-card" data-account="${name}">
|
|
<div class="account-card-info">
|
|
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, hoster))}</div>
|
|
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message ? ` • ${escapeHtml(st.message)}` : ''}</div>
|
|
</div>
|
|
<span class="account-status status-${st.status}">
|
|
<span class="account-status-dot"></span>
|
|
${statusLabel}
|
|
</span>
|
|
<div class="account-card-actions">
|
|
<button class="btn btn-xs btn-secondary" data-account-check="${name}">Prüfen</button>
|
|
<button class="btn btn-xs btn-secondary" data-account-edit="${name}">Bearbeiten</button>
|
|
<button class="btn btn-xs btn-danger" data-account-delete="${name}">Löschen</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Wire up buttons
|
|
container.querySelectorAll('[data-account-edit]').forEach(btn => {
|
|
btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit));
|
|
});
|
|
container.querySelectorAll('[data-account-delete]').forEach(btn => {
|
|
btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete));
|
|
});
|
|
container.querySelectorAll('[data-account-check]').forEach(btn => {
|
|
btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck));
|
|
});
|
|
}
|
|
|
|
async function checkSingleAccount(hosterName) {
|
|
if (!hosterName || healthCheckRunning) return;
|
|
healthCheckRunning = true;
|
|
accountStatuses[hosterName] = { status: 'checking', message: '' };
|
|
renderAccounts();
|
|
try {
|
|
const rows = await executeHealthCheck([hosterName], 'manual');
|
|
const row = rows.find(r => r.hoster === hosterName);
|
|
if (row) accountStatuses[hosterName] = { status: row.status || 'error', message: row.message || '' };
|
|
} catch (err) {
|
|
accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
|
|
} finally {
|
|
healthCheckRunning = false;
|
|
}
|
|
renderAccounts();
|
|
}
|
|
|
|
function getCredsFieldsHtml(name, hoster) {
|
|
hoster = hoster || {};
|
|
if (name === 'vidmoly.me') {
|
|
return `
|
|
<div class="settings-row">
|
|
<label>Username</label>
|
|
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(hoster.username || '')}" placeholder="Username">
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Passwort</label>
|
|
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(hoster.password || '')}" placeholder="Passwort">
|
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
|
</div>`;
|
|
}
|
|
if (name === 'voe.sx' || name === 'doodstream.com') {
|
|
return `
|
|
<div class="settings-row">
|
|
<label>E-Mail (Login)</label>
|
|
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(hoster.username || '')}" placeholder="E-Mail für Login">
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Passwort (Login)</label>
|
|
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(hoster.password || '')}" placeholder="Passwort für Login">
|
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>API Key (optional)</label>
|
|
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(hoster.apiKey || '')}" placeholder="API Key (Fallback)">
|
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
|
</div>
|
|
<p class="hint" style="margin:4px 0 0;opacity:0.6">Login wird bevorzugt. API-Key nur als Fallback.</p>`;
|
|
}
|
|
// Default: API key only
|
|
return `
|
|
<div class="settings-row">
|
|
<label>API Key</label>
|
|
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(hoster.apiKey || '')}" placeholder="API Key">
|
|
<button class="toggle-vis" type="button" title="Anzeigen">👁</button>
|
|
</div>`;
|
|
}
|
|
|
|
function openAccountModal(editHoster) {
|
|
editingAccountHoster = editHoster || null;
|
|
const modal = document.getElementById('accountModal');
|
|
const title = document.getElementById('accountModalTitle');
|
|
const subtitle = document.getElementById('accountModalSubtitle');
|
|
const hosterRow = document.getElementById('accountHosterRow');
|
|
const hosterSelect = document.getElementById('accountHosterSelect');
|
|
const credsContainer = document.getElementById('accountCredsFields');
|
|
const statusEl = document.getElementById('accountModalStatus');
|
|
const saveBtn = document.getElementById('saveAccountBtn');
|
|
|
|
statusEl.textContent = '';
|
|
statusEl.className = 'account-modal-status';
|
|
|
|
if (editingAccountHoster) {
|
|
// Edit mode
|
|
title.textContent = 'Account bearbeiten';
|
|
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(editingAccountHoster, config.hosters[editingAccountHoster] || {})} bearbeiten.`;
|
|
hosterRow.style.display = 'none';
|
|
saveBtn.textContent = 'Speichern & prüfen';
|
|
const hoster = config.hosters[editingAccountHoster] || {};
|
|
credsContainer.innerHTML = getCredsFieldsHtml(editingAccountHoster, hoster);
|
|
} else {
|
|
// Add mode
|
|
title.textContent = 'Account hinzufügen';
|
|
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.';
|
|
hosterRow.style.display = 'flex';
|
|
saveBtn.textContent = 'Anlegen & prüfen';
|
|
const available = getHostersWithoutCreds();
|
|
if (available.length === 0) {
|
|
hosterSelect.innerHTML = '<option value="">Alle Hoster bereits eingerichtet</option>';
|
|
credsContainer.innerHTML = '';
|
|
} else {
|
|
hosterSelect.innerHTML = available.map(name => `<option value="${name}">${getHosterLabel(name)}</option>`).join('');
|
|
credsContainer.innerHTML = getCredsFieldsHtml(available[0], {});
|
|
}
|
|
}
|
|
|
|
// Toggle visibility buttons
|
|
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const input = btn.previousElementSibling;
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
});
|
|
});
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeAccountModal() {
|
|
document.getElementById('accountModal').style.display = 'none';
|
|
editingAccountHoster = null;
|
|
}
|
|
|
|
function openDeleteAccountModal(hosterName) {
|
|
const modal = document.getElementById('deleteAccountModal');
|
|
const msg = document.getElementById('deleteAccountMessage');
|
|
msg.textContent = `Account für "${getHosterLabel(hosterName)}" wirklich löschen? Alle Zugangsdaten werden entfernt.`;
|
|
modal.dataset.hoster = hosterName;
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
document.getElementById('deleteAccountModal').style.display = 'none';
|
|
}
|
|
|
|
async function deleteAccount(hosterName) {
|
|
const hosters = { ...config.hosters };
|
|
// Reset credentials to defaults
|
|
if (hosterName === 'vidmoly.me') {
|
|
hosters[hosterName] = { enabled: false, authType: 'login', username: '', password: '' };
|
|
} else if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') {
|
|
hosters[hosterName] = { enabled: false, username: '', password: '', apiKey: '' };
|
|
} else {
|
|
hosters[hosterName] = { enabled: false, apiKey: '' };
|
|
}
|
|
delete accountStatuses[hosterName];
|
|
await window.api.saveConfig({ hosters });
|
|
config = await window.api.getConfig();
|
|
ensureAccountStatusEntries();
|
|
syncSelectedUploadHosters();
|
|
if (getAccountsWithCreds().length === 0) renderHealthCheckResults([]);
|
|
renderAccounts();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
renderSettings();
|
|
closeDeleteModal();
|
|
}
|
|
|
|
function readAccountCredsFromModal(hosterName) {
|
|
if (hosterName === 'vidmoly.me') {
|
|
const username = (document.getElementById('accField_username')?.value || '').trim();
|
|
const password = (document.getElementById('accField_password')?.value || '').trim();
|
|
return { enabled: !!(username && password), authType: 'login', username, password };
|
|
}
|
|
if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') {
|
|
const username = (document.getElementById('accField_username')?.value || '').trim();
|
|
const password = (document.getElementById('accField_password')?.value || '').trim();
|
|
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
|
|
return { enabled: !!(username && password) || !!apiKey, username, password, apiKey };
|
|
}
|
|
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
|
|
return { enabled: !!apiKey, apiKey };
|
|
}
|
|
|
|
async function saveAccount() {
|
|
const hosterName = editingAccountHoster || document.getElementById('accountHosterSelect')?.value;
|
|
if (!hosterName) return;
|
|
|
|
const creds = readAccountCredsFromModal(hosterName);
|
|
if (!creds.enabled) {
|
|
const statusEl = document.getElementById('accountModalStatus');
|
|
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
|
|
statusEl.className = 'account-modal-status error';
|
|
return;
|
|
}
|
|
|
|
// Save credentials
|
|
const hosters = { ...config.hosters };
|
|
hosters[hosterName] = creds;
|
|
await window.api.saveConfig({ hosters });
|
|
config = await window.api.getConfig();
|
|
|
|
// Show checking status
|
|
const statusEl = document.getElementById('accountModalStatus');
|
|
const saveBtn = document.getElementById('saveAccountBtn');
|
|
statusEl.textContent = 'Prüfe Login...';
|
|
statusEl.className = 'account-modal-status checking';
|
|
saveBtn.disabled = true;
|
|
|
|
accountStatuses[hosterName] = { status: 'checking', message: '' };
|
|
syncSelectedUploadHosters();
|
|
renderAccounts();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
renderSettings();
|
|
|
|
// Run health check
|
|
try {
|
|
const rows = await executeHealthCheck([hosterName], 'auto');
|
|
const row = rows.find(r => r.hoster === hosterName);
|
|
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
|
accountStatuses[hosterName] = { status: row.status || 'ok', message: row.message || '' };
|
|
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!';
|
|
statusEl.className = 'account-modal-status ok';
|
|
setTimeout(() => closeAccountModal(), 1200);
|
|
} else {
|
|
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
|
accountStatuses[hosterName] = { status: 'error', message: msg };
|
|
statusEl.textContent = msg;
|
|
statusEl.className = 'account-modal-status error';
|
|
}
|
|
} catch (err) {
|
|
accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
|
|
statusEl.textContent = err.message || 'Prüfung fehlgeschlagen';
|
|
statusEl.className = 'account-modal-status error';
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
ensureAccountStatusEntries();
|
|
renderAccounts();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
renderSettings();
|
|
}
|
|
}
|
|
|
|
// --- History ---
|
|
async function loadHistory() {
|
|
const history = await window.api.getHistory();
|
|
const container = document.getElementById('historyContainer');
|
|
|
|
if (!history || history.length === 0) {
|
|
historyRowsData = [];
|
|
container.innerHTML = '<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);
|
|
}
|
|
|
|
function sortRecentFiles(data) {
|
|
const sorted = data.slice();
|
|
const { key, direction } = recentSortState;
|
|
const dir = direction === 'asc' ? 1 : -1;
|
|
sorted.sort((a, b) => {
|
|
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
|
|
if (key === 'filename') return dir * a.filename.localeCompare(b.filename, 'de', { sensitivity: 'base' });
|
|
if (key === 'host') return dir * a.host.localeCompare(b.host, 'de', { sensitivity: 'base' });
|
|
if (key === 'link') return dir * a.link.localeCompare(b.link, 'de', { sensitivity: 'base' });
|
|
return 0;
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
function updateRecentSortHeaders() {
|
|
const head = document.getElementById('recentFilesHead');
|
|
if (!head) return;
|
|
head.querySelectorAll('th[data-recent-sort]').forEach(th => {
|
|
const key = th.dataset.recentSort;
|
|
const active = recentSortState.key === key;
|
|
const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
|
th.classList.toggle('active', active);
|
|
const indicator = th.querySelector('.sort-indicator');
|
|
if (indicator) indicator.textContent = arrow;
|
|
});
|
|
}
|
|
|
|
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>';
|
|
return;
|
|
}
|
|
|
|
const rows = sortRecentFiles(sessionFilesData);
|
|
|
|
tbody.innerHTML = rows.map(row => `
|
|
<tr class="recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}" 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>
|
|
`).join('');
|
|
|
|
tbody.querySelectorAll('.recent-file-row').forEach(tr => {
|
|
tr.addEventListener('click', (e) => {
|
|
const id = parseInt(tr.dataset.order, 10);
|
|
if (e.ctrlKey || e.metaKey) {
|
|
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
|
|
else selectedRecentIds.add(id);
|
|
} else if (e.shiftKey && selectedRecentIds.size > 0) {
|
|
const sortedOrders = rows.map(r => r.order);
|
|
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
|
|
const curIdx = sortedOrders.indexOf(id);
|
|
if (lastIdx >= 0 && curIdx >= 0) {
|
|
const from = Math.min(lastIdx, curIdx);
|
|
const to = Math.max(lastIdx, curIdx);
|
|
for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]);
|
|
}
|
|
} else {
|
|
selectedRecentIds.clear();
|
|
selectedRecentIds.add(id);
|
|
}
|
|
renderRecentUploadsPanel();
|
|
});
|
|
|
|
tr.addEventListener('dblclick', () => {
|
|
if (tr.classList.contains('error')) return;
|
|
const link = tr.dataset.link;
|
|
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
|
});
|
|
});
|
|
|
|
updateRecentSortHeaders();
|
|
}
|
|
|
|
function renderHistoryTable(container) {
|
|
if (!container || !historyRowsData.length) {
|
|
if (container) container.innerHTML = '<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) => {
|
|
let cmp = key === 'date' ? a.dateTs - b.dateTs : String(a[key] || '').localeCompare(String(b[key] || ''), 'de', { sensitivity: 'base', numeric: true });
|
|
return (cmp || a.order - b.order) * factor;
|
|
});
|
|
}
|
|
|
|
// Flush pending queue state on window close
|
|
window.addEventListener('beforeunload', () => {
|
|
if (queuePersistTimer) {
|
|
clearTimeout(queuePersistTimer);
|
|
queuePersistTimer = null;
|
|
// Synchronous-ish: fire and forget since window is closing
|
|
persistQueueStateNow().catch(() => {});
|
|
}
|
|
});
|
|
|
|
// --- Setup Listeners ---
|
|
function setupListeners() {
|
|
document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
|
|
document.getElementById('chooseHostersBtn').addEventListener('click', openHosterModal);
|
|
document.getElementById('startUploadBtn').addEventListener('click', startUpload);
|
|
document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload);
|
|
|
|
// Recent files sort headers
|
|
document.getElementById('recentFilesHead').addEventListener('click', (e) => {
|
|
const th = e.target.closest('th[data-recent-sort]');
|
|
if (!th) return;
|
|
const key = th.dataset.recentSort;
|
|
if (recentSortState.key === key) {
|
|
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc';
|
|
} else {
|
|
recentSortState.key = key;
|
|
recentSortState.direction = key === 'date' ? 'desc' : 'asc';
|
|
}
|
|
renderRecentUploadsPanel();
|
|
});
|
|
|
|
// Recent files context menu
|
|
document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => {
|
|
const tr = e.target.closest('.recent-file-row');
|
|
if (!tr) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = parseInt(tr.dataset.order, 10);
|
|
if (!selectedRecentIds.has(id)) {
|
|
selectedRecentIds.clear();
|
|
selectedRecentIds.add(id);
|
|
renderRecentUploadsPanel();
|
|
}
|
|
const menu = document.getElementById('recentContextMenu');
|
|
menu.style.display = 'block';
|
|
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
|
menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px';
|
|
});
|
|
|
|
document.getElementById('recentContextMenu').addEventListener('click', (e) => {
|
|
const item = e.target.closest('.ctx-item');
|
|
if (!item) return;
|
|
hideContextMenu();
|
|
const action = item.dataset.action;
|
|
if (action === 'recent-copy-links') copySelectedRecentLinks();
|
|
else if (action === 'recent-delete') deleteSelectedRecentFiles();
|
|
});
|
|
document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs);
|
|
document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs);
|
|
document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress);
|
|
document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads);
|
|
document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top'));
|
|
document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up'));
|
|
document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down'));
|
|
document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom'));
|
|
document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual'));
|
|
document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks);
|
|
document.getElementById('retryFailedBtn').addEventListener('click', () => {
|
|
queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); });
|
|
retrySelectedJobs();
|
|
});
|
|
document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection);
|
|
document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal);
|
|
document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal);
|
|
document.getElementById('selectAllHostersBtn').addEventListener('click', () => {
|
|
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
|
input.checked = true;
|
|
input.closest('.hoster-option')?.classList.add('selected');
|
|
});
|
|
});
|
|
document.getElementById('clearHostersBtn').addEventListener('click', () => {
|
|
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
|
input.checked = false;
|
|
input.closest('.hoster-option')?.classList.remove('selected');
|
|
});
|
|
});
|
|
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
|
|
|
// --- Backup export / import ---
|
|
document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export'));
|
|
document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import'));
|
|
document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal);
|
|
document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal);
|
|
document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction);
|
|
document.getElementById('backupPasswordModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'backupPasswordModal') closeBackupModal();
|
|
});
|
|
document.getElementById('backupPassword').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') confirmBackupAction();
|
|
});
|
|
document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') confirmBackupAction();
|
|
});
|
|
|
|
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
|
if (!confirm('Verlauf wirklich löschen?')) return;
|
|
await window.api.clearHistory();
|
|
loadHistory();
|
|
});
|
|
|
|
// Auto health check toggle
|
|
const autoToggle = document.getElementById('autoHealthCheckToggle');
|
|
if (autoToggle) {
|
|
autoToggle.checked = autoHealthCheckEnabled;
|
|
autoToggle.addEventListener('change', (e) => {
|
|
autoHealthCheckEnabled = !!e.target.checked;
|
|
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
|
|
});
|
|
}
|
|
|
|
// Virtual scroll for large queues
|
|
const queueContainer = document.getElementById('queueContainer');
|
|
if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true });
|
|
|
|
// Queue table sorting
|
|
document.querySelectorAll('#queueTable th.sortable').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
const key = th.dataset.sort;
|
|
if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc';
|
|
else { queueSortState.key = key; queueSortState.direction = 'asc'; }
|
|
renderQueueTable();
|
|
});
|
|
});
|
|
|
|
// Shutdown cancel
|
|
document.getElementById('cancelShutdownBtn').addEventListener('click', async () => {
|
|
await window.api.cancelShutdown();
|
|
if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; }
|
|
document.getElementById('shutdownOverlay').style.display = 'none';
|
|
});
|
|
|
|
// Click on empty area in queue → deselect all
|
|
document.getElementById('upload-view').addEventListener('click', (e) => {
|
|
if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) {
|
|
if (selectedJobIds.size > 0) {
|
|
selectedJobIds.clear();
|
|
renderQueueTable();
|
|
updateQueueActionButtons();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Right-click on upload view background
|
|
document.getElementById('upload-view').addEventListener('contextmenu', (e) => {
|
|
if (e.target.closest('.queue-row')) return; // handled per row
|
|
e.preventDefault();
|
|
showContextMenu(e.clientX, e.clientY);
|
|
});
|
|
|
|
document.getElementById('hosterModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'hosterModal') cancelHosterModal();
|
|
});
|
|
|
|
// Account management
|
|
document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null));
|
|
document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal);
|
|
document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal);
|
|
document.getElementById('saveAccountBtn').addEventListener('click', saveAccount);
|
|
document.getElementById('accountModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'accountModal') closeAccountModal();
|
|
});
|
|
|
|
// Account hoster select change → update credential fields
|
|
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
|
const credsContainer = document.getElementById('accountCredsFields');
|
|
credsContainer.innerHTML = getCredsFieldsHtml(e.target.value, {});
|
|
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const input = btn.previousElementSibling;
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
});
|
|
});
|
|
document.getElementById('accountModalStatus').textContent = '';
|
|
document.getElementById('accountModalStatus').className = 'account-modal-status';
|
|
});
|
|
|
|
// Delete account modal
|
|
document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal);
|
|
document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal);
|
|
document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
|
|
const modal = document.getElementById('deleteAccountModal');
|
|
const hoster = modal.dataset.hoster;
|
|
if (hoster) deleteAccount(hoster);
|
|
});
|
|
document.getElementById('deleteAccountModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'deleteAccountModal') closeDeleteModal();
|
|
});
|
|
}
|
|
|
|
// --- Update UI ---
|
|
function showUpdateBanner(info) {
|
|
const banner = document.getElementById('updateBanner');
|
|
const msg = document.getElementById('updateMessage');
|
|
if (!banner || !msg) return;
|
|
msg.textContent = `Update v${info.remoteVersion} verfügbar`;
|
|
banner.style.display = 'flex';
|
|
document.getElementById('installUpdateBtn').onclick = async () => {
|
|
msg.textContent = 'Update wird heruntergeladen...';
|
|
document.getElementById('installUpdateBtn').disabled = true;
|
|
await window.api.installUpdate();
|
|
};
|
|
document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; };
|
|
}
|
|
|
|
function handleUpdateProgress(data) {
|
|
const msg = document.getElementById('updateMessage');
|
|
if (!msg) return;
|
|
if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`;
|
|
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
|
|
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
|
|
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';
|
|
else if (data.stage === 'error') {
|
|
msg.textContent = `Update fehlgeschlagen: ${data.error}`;
|
|
const btn = document.getElementById('installUpdateBtn');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
|
|
}
|
|
}
|
|
|
|
// --- Shutdown ---
|
|
let shutdownCountdownInterval = null;
|
|
function handleShutdownCountdown(data) {
|
|
const overlay = document.getElementById('shutdownOverlay');
|
|
const msgEl = document.getElementById('shutdownMessage');
|
|
const secEl = document.getElementById('shutdownSeconds');
|
|
overlay.style.display = 'flex';
|
|
|
|
const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' };
|
|
let remaining = data.seconds || 60;
|
|
secEl.textContent = remaining;
|
|
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
|
|
|
|
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
|
|
shutdownCountdownInterval = setInterval(() => {
|
|
remaining--;
|
|
secEl.textContent = remaining;
|
|
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
|
|
if (remaining <= 0) { clearInterval(shutdownCountdownInterval); }
|
|
}, 1000);
|
|
}
|
|
|
|
// --- Link operations ---
|
|
function copyAllLinks() {
|
|
const links = queueJobs
|
|
.filter(j => j.status === 'done' && j.result)
|
|
.map(j => j.result.download_url || j.result.embed_url || '')
|
|
.filter(Boolean);
|
|
if (links.length > 0) {
|
|
window.api.copyToClipboard(links.join('\n'));
|
|
showCopyToast(`${links.length} Links kopiert`);
|
|
}
|
|
}
|
|
|
|
// --- Utilities ---
|
|
function formatSize(bytes) {
|
|
if (!bytes || bytes <= 0) return '0 B';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatSpeed(kbs) {
|
|
if (!kbs || kbs <= 0) return '0 kB/s';
|
|
if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s';
|
|
return Math.round(kbs) + ' kB/s';
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
if (!seconds || seconds <= 0) return '00:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
return `${pad(m)}:${pad(s)}`;
|
|
}
|
|
|
|
function pad(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
|
|
|
function formatDateTime(value) {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date;
|
|
return {
|
|
ts: safeDate.getTime(),
|
|
text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
+ ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
};
|
|
}
|
|
|
|
function loadAutoCheckPreference() {
|
|
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
|
|
catch { return true; }
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function showCopyToast(msg) {
|
|
const toast = document.getElementById('copyToast');
|
|
toast.textContent = msg;
|
|
toast.classList.add('show');
|
|
clearTimeout(toast._timer);
|
|
toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
|
|
}
|
|
|
|
// --- Resize handle for recent-files panel ---
|
|
{
|
|
const resizer = document.getElementById('recentFilesResizer');
|
|
const panel = document.getElementById('recentFilesPanel');
|
|
if (resizer && panel) {
|
|
let startY = 0;
|
|
let startH = 0;
|
|
|
|
resizer.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
startY = e.clientY;
|
|
startH = panel.getBoundingClientRect().height;
|
|
resizer.classList.add('dragging');
|
|
document.body.style.cursor = 'ns-resize';
|
|
document.body.style.userSelect = 'none';
|
|
|
|
const onMove = (e2) => {
|
|
const delta = startY - e2.clientY;
|
|
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta));
|
|
panel.style.flex = `0 0 ${newH}px`;
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
resizer.classList.remove('dragging');
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
};
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Recent panel tabs ---
|
|
document.querySelectorAll('.recent-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const panel = document.getElementById(tab.dataset.panel);
|
|
if (panel) panel.classList.add('active');
|
|
const hint = document.getElementById('recentFilesHint');
|
|
if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links';
|
|
});
|
|
});
|
|
|
|
// --- Stats panel update ---
|
|
let statsStartTime = 0;
|
|
let statsRunTimer = null;
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i];
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
function updateStatsPanel() {
|
|
const total = queueJobs.length;
|
|
const done = queueJobs.filter(j => j.status === 'done').length;
|
|
const inProgress = queueJobs.filter(j => ['uploading', 'getting-server', 'retrying'].includes(j.status)).length;
|
|
const errors = queueJobs.filter(j => j.status === 'error').length;
|
|
const remaining = total - done - errors;
|
|
|
|
const totalSize = queueJobs.reduce((s, j) => s + (j.bytesTotal || 0), 0);
|
|
const remainingSize = queueJobs.filter(j => !['done', 'error', 'skipped'].includes(j.status))
|
|
.reduce((s, j) => s + ((j.bytesTotal || 0) - (j.bytesUploaded || 0)), 0);
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
if (el('statQueueTotal')) el('statQueueTotal').textContent = total;
|
|
if (el('statQueueDone')) el('statQueueDone').textContent = done;
|
|
if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining;
|
|
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = inProgress;
|
|
if (el('statQueueError')) el('statQueueError').textContent = errors;
|
|
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(totalSize);
|
|
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(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 && remainingSize > 0) {
|
|
el('statEta').textContent = formatDuration(Math.round(remainingSize / (speed * 1024)));
|
|
} else {
|
|
el('statEta').textContent = '--:--';
|
|
}
|
|
}
|
|
if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0);
|
|
}
|
|
|
|
// --- Start ---
|
|
init();
|