Root cause: startBatch() ran synchronously inside ipcMain.handle() callback, causing webContents.send() events to conflict with the handle response and never reach the renderer. Fix: defer startBatch() via process.nextTick so IPC response is sent first, then upload events flow correctly. Also: - Add .catch() on startBatch to surface hidden errors - Fix settings panel not updating after save (renderSettings) - Add select-folder IPC handler (was in preload but missing) - Add debug-log and debug-test-upload IPC for diagnostics - Add upload-debug.log file for tracing upload flow - Add unhandledRejection handler for main process - Add scramble defaults to config-store globalSettings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1022 lines
38 KiB
JavaScript
1022 lines
38 KiB
JavaScript
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'];
|
|
|
|
// --- State ---
|
|
let selectedFiles = []; // { path, name, size }
|
|
let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
|
|
let hosterSettings = {};
|
|
let uploading = false;
|
|
let healthCheckRunning = false;
|
|
let autoHealthCheckEnabled = true;
|
|
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 selectedJobIds = new Set();
|
|
let queueSortState = { key: 'filename', direction: 'asc' };
|
|
|
|
// History state
|
|
let historyRowsData = [];
|
|
let historySortState = { key: 'date', direction: 'desc' };
|
|
|
|
// --- Init ---
|
|
async function init() {
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
autoHealthCheckEnabled = loadAutoCheckPreference();
|
|
renderHosterChips();
|
|
renderSettings();
|
|
setupListeners();
|
|
setupDragDrop();
|
|
loadHistory();
|
|
|
|
// 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 {}
|
|
}
|
|
|
|
// --- 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 chips ---
|
|
function hosterHasCredentials(name, hoster) {
|
|
if (name === 'vidmoly.me') return !!(hoster.username && hoster.password);
|
|
return !!hoster.apiKey;
|
|
}
|
|
|
|
function renderHosterChips() {
|
|
const container = document.getElementById('hosterSelect');
|
|
container.innerHTML = '';
|
|
for (const name of HOSTERS) {
|
|
const hoster = config.hosters[name] || {};
|
|
const hasCreds = hosterHasCredentials(name, hoster);
|
|
const chip = document.createElement('label');
|
|
chip.className = 'hoster-chip' + (hoster.enabled && hasCreds ? ' selected' : '') + (!hasCreds ? ' no-key' : '');
|
|
chip.innerHTML = `
|
|
<input type="checkbox" data-hoster="${name}" ${hoster.enabled && hasCreds ? 'checked' : ''} ${!hasCreds ? 'disabled' : ''}>
|
|
<span class="hoster-dot"></span>
|
|
<span>${name}</span>
|
|
`;
|
|
chip.querySelector('input').addEventListener('change', (e) => {
|
|
chip.classList.toggle('selected', e.target.checked);
|
|
if (!uploading && selectedFiles.length > 0) buildQueuePreview();
|
|
updateStartButton();
|
|
});
|
|
container.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
function getSelectedHosters() {
|
|
return Array.from(document.querySelectorAll('#hosterSelect input:checked'))
|
|
.map(cb => cb.dataset.hoster);
|
|
}
|
|
|
|
// --- 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);
|
|
});
|
|
}
|
|
|
|
function addDroppedFiles(fileList) {
|
|
const files = Array.from(fileList);
|
|
for (const file of files) {
|
|
if (!selectedFiles.find(f => f.path === file.path)) {
|
|
selectedFiles.push({ path: file.path, name: file.name, size: file.size });
|
|
}
|
|
}
|
|
updateUploadView();
|
|
}
|
|
|
|
async function pickFiles() {
|
|
const paths = await window.api.selectFiles();
|
|
if (!paths) return;
|
|
for (const p of paths) {
|
|
if (!selectedFiles.find(f => f.path === p)) {
|
|
const name = p.split('\\').pop().split('/').pop();
|
|
selectedFiles.push({ path: p, name, size: null }); // size resolved by upload-manager
|
|
}
|
|
}
|
|
updateUploadView();
|
|
}
|
|
|
|
function updateUploadView() {
|
|
const dropZone = document.getElementById('dropZone');
|
|
const queueContainer = document.getElementById('queueContainer');
|
|
const queueActions = document.getElementById('queueActions');
|
|
|
|
if (selectedFiles.length === 0 && queueJobs.length === 0) {
|
|
dropZone.style.display = 'flex';
|
|
queueContainer.style.display = 'none';
|
|
queueActions.style.display = 'none';
|
|
} else {
|
|
dropZone.style.display = 'none';
|
|
queueContainer.style.display = 'block';
|
|
queueActions.style.display = 'flex';
|
|
if (!uploading && selectedFiles.length > 0) {
|
|
buildQueuePreview();
|
|
}
|
|
}
|
|
updateStartButton();
|
|
}
|
|
|
|
function updateStartButton() {
|
|
const btn = document.getElementById('startUploadBtn');
|
|
const hosters = getSelectedHosters();
|
|
const hasFiles = selectedFiles.length > 0 || queueJobs.some(j => j.status === 'queued' || j.status === 'error' || j.status === 'preview');
|
|
btn.disabled = uploading || hosters.length === 0 || !hasFiles;
|
|
}
|
|
|
|
// Build preview jobs from selected files x selected hosters (before upload starts)
|
|
function buildQueuePreview() {
|
|
const hosters = getSelectedHosters();
|
|
// Remove old preview jobs (status 'preview')
|
|
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
|
|
|
for (const file of selectedFiles) {
|
|
for (const hoster of hosters) {
|
|
// Don't add if already in queue (from a previous upload)
|
|
const exists = queueJobs.find(j => j.file === file.path && j.hoster === hoster && j.status !== 'error');
|
|
if (!exists) {
|
|
queueJobs.push({
|
|
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: ''
|
|
});
|
|
}
|
|
}
|
|
}
|
|
renderQueueTable();
|
|
}
|
|
|
|
// --- Queue Table Rendering (debounced) ---
|
|
let _renderQueued = false;
|
|
function scheduleQueueRender() {
|
|
if (_renderQueued) return;
|
|
_renderQueued = true;
|
|
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
|
|
}
|
|
|
|
function renderQueueTable() {
|
|
const tbody = document.getElementById('queueBody');
|
|
if (!tbody) return;
|
|
|
|
// Preserve scroll position
|
|
const scrollContainer = document.getElementById('queueContainer');
|
|
const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
|
|
|
|
const sorted = sortQueueJobs(queueJobs);
|
|
|
|
tbody.innerHTML = sorted.map((job) => {
|
|
const statusClass = `status-${job.status}`;
|
|
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
|
|
const uploadedSize = job.status === 'preview'
|
|
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
|
|
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
|
|
const statusText = getStatusText(job);
|
|
const elapsed = formatTime(job.elapsed);
|
|
const remaining = formatTime(job.remaining);
|
|
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
|
|
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
|
|
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
|
|
|
|
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}">
|
|
<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>`;
|
|
}).join('');
|
|
|
|
// Restore scroll position
|
|
if (scrollContainer) scrollContainer.scrollTop = scrollTop;
|
|
|
|
// Attach click handlers
|
|
tbody.querySelectorAll('.queue-row').forEach(row => {
|
|
row.addEventListener('click', (e) => handleRowClick(e, row));
|
|
row.addEventListener('contextmenu', (e) => handleRowContextMenu(e, row));
|
|
});
|
|
|
|
// Update retry button visibility
|
|
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
|
|
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
|
|
}
|
|
|
|
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);
|
|
return cmp * factor;
|
|
});
|
|
}
|
|
|
|
function getStatusOrder(status) {
|
|
const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, error: 6, skipped: 7 };
|
|
return order[status] ?? 4;
|
|
}
|
|
|
|
function getStatusText(job) {
|
|
switch (job.status) {
|
|
case 'preview': return 'Ready';
|
|
case 'queued': return 'Queued';
|
|
case 'getting-server': return 'Server...';
|
|
case 'uploading': return 'Process';
|
|
case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`;
|
|
case 'done': return 'Done';
|
|
case 'error': return 'Failed';
|
|
case 'skipped': return 'Skipped';
|
|
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) {
|
|
const allRows = Array.from(document.querySelectorAll('.queue-row'));
|
|
const lastIdx = allRows.findIndex(r => selectedJobIds.has(r.dataset.jobId));
|
|
const curIdx = allRows.indexOf(row);
|
|
const from = Math.min(lastIdx, curIdx);
|
|
const to = Math.max(lastIdx, curIdx);
|
|
for (let i = from; i <= to; i++) selectedJobIds.add(allRows[i].dataset.jobId);
|
|
} 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';
|
|
|
|
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.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.context-menu')) hideContextMenu();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') hideContextMenu();
|
|
});
|
|
|
|
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 === '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 => !selectedJobIds.has(j.id));
|
|
selectedJobIds.clear();
|
|
renderQueueTable();
|
|
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
|
|
} else if (action === 'copy-all-links') {
|
|
copyAllLinks();
|
|
} else if (action === 'delete-all') {
|
|
if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); }
|
|
} 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 auswaehlen.'); return; }
|
|
|
|
// Include files from preview/queued jobs that may not be in selectedFiles (e.g. retries)
|
|
const previewFiles = queueJobs
|
|
.filter(j => j.status === 'preview' || j.status === 'queued')
|
|
.map(j => j.file)
|
|
.filter(Boolean);
|
|
for (const fp of previewFiles) {
|
|
if (!selectedFiles.find(f => f.path === fp)) {
|
|
const job = queueJobs.find(j => j.file === fp);
|
|
selectedFiles.push({ path: fp, name: job ? job.fileName : fp.split(/[\\/]/).pop(), size: job ? job.bytesTotal : null });
|
|
}
|
|
}
|
|
|
|
if (selectedFiles.length === 0 && previewFiles.length === 0) return;
|
|
|
|
// Auto health check
|
|
if (autoHealthCheckEnabled) {
|
|
const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me');
|
|
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;
|
|
// Convert preview jobs to queued
|
|
queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; });
|
|
renderQueueTable();
|
|
|
|
document.getElementById('startUploadBtn').style.display = 'none';
|
|
document.getElementById('cancelUploadBtn').style.display = 'inline-block';
|
|
|
|
const uploadPayload = {
|
|
files: selectedFiles.map(f => f.path),
|
|
hosters
|
|
};
|
|
console.log('[startUpload] sending payload:', uploadPayload);
|
|
const result = await window.api.startUpload(uploadPayload);
|
|
console.log('[startUpload] response:', result);
|
|
|
|
if (result && result.error) {
|
|
alert(result.error);
|
|
uploading = false;
|
|
document.getElementById('startUploadBtn').style.display = 'inline-block';
|
|
document.getElementById('cancelUploadBtn').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function cancelUpload() {
|
|
await window.api.cancelUpload();
|
|
uploading = false;
|
|
document.getElementById('startUploadBtn').style.display = 'inline-block';
|
|
document.getElementById('cancelUploadBtn').style.display = 'none';
|
|
updateStartButton();
|
|
}
|
|
|
|
// --- Progress handling ---
|
|
function handleProgress(data) {
|
|
console.log('[upload-progress]', data.status, data.hoster, data.fileName, data.error || '');
|
|
// Find matching job by fileName + hoster, or by uploadId
|
|
let job = data.uploadId ? queueJobs.find(j => j.uploadId === data.uploadId) : null;
|
|
if (!job) {
|
|
// Match by file+hoster for queued/preview jobs (prefer queued, then preview)
|
|
job = queueJobs.find(j =>
|
|
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
|
|
) || queueJobs.find(j =>
|
|
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
|
|
);
|
|
if (job && data.uploadId) job.uploadId = data.uploadId;
|
|
}
|
|
if (!job) {
|
|
// Create new job entry
|
|
job = {
|
|
id: 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);
|
|
}
|
|
|
|
// 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;
|
|
|
|
scheduleQueueRender();
|
|
}
|
|
|
|
function handleBatchDone(summary) {
|
|
console.log('[batch-done]', summary);
|
|
uploading = false;
|
|
selectedFiles = []; // Clear selected files after batch
|
|
document.getElementById('startUploadBtn').style.display = 'inline-block';
|
|
document.getElementById('cancelUploadBtn').style.display = 'none';
|
|
updateStartButton();
|
|
renderQueueTable();
|
|
|
|
// Final stats update
|
|
document.getElementById('sbState').textContent = 'Fertig';
|
|
}
|
|
|
|
function handleStats(data) {
|
|
console.log('[upload-stats]', data.state, 'active=' + data.activeJobs);
|
|
document.getElementById('sbState').textContent = data.state === 'uploading' ? 'Upload laeuft...' : 'Bereit';
|
|
document.getElementById('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0);
|
|
document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0);
|
|
document.getElementById('sbElapsed').textContent = formatTime(data.elapsed || 0);
|
|
}
|
|
|
|
// --- Retry ---
|
|
function retrySelectedJobs() {
|
|
// For now just mark failed jobs back to preview so user can restart
|
|
queueJobs.forEach(j => {
|
|
if (selectedJobIds.has(j.id) && j.status === 'error') {
|
|
j.status = 'preview';
|
|
j.error = null;
|
|
j.bytesUploaded = 0;
|
|
j.speedKbs = 0;
|
|
j.elapsed = 0;
|
|
j.remaining = 0;
|
|
j.progress = 0;
|
|
j.uploadId = null;
|
|
// Re-add to selectedFiles if not present
|
|
if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) {
|
|
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
|
|
}
|
|
}
|
|
});
|
|
selectedJobIds.clear();
|
|
renderQueueTable();
|
|
updateStartButton();
|
|
}
|
|
|
|
// --- 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 || '')}</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 : [];
|
|
renderHealthCheckResults(rows);
|
|
return rows;
|
|
}
|
|
|
|
async function runHealthCheck() {
|
|
if (healthCheckRunning || uploading) return;
|
|
const hosters = getSelectedHosters().filter(n => n === 'doodstream.com' || n === 'vidmoly.me');
|
|
if (hosters.length === 0) {
|
|
const allHosters = ['doodstream.com', 'vidmoly.me'].filter(n => hosterHasCredentials(n, config.hosters[n] || {}));
|
|
if (allHosters.length === 0) { alert('Keine Hoster mit Zugangsdaten fuer Health-Check.'); return; }
|
|
hosters.push(...allHosters);
|
|
}
|
|
healthCheckRunning = true;
|
|
try { await executeHealthCheck(hosters, 'manual'); }
|
|
catch (err) { renderHealthCheckResults([{ hoster: 'system', status: 'error', message: err.message }]); }
|
|
finally { healthCheckRunning = false; }
|
|
}
|
|
|
|
// --- Settings ---
|
|
function renderSettings() {
|
|
const container = document.getElementById('settingsHosters');
|
|
container.innerHTML = '';
|
|
|
|
for (const name of HOSTERS) {
|
|
const hoster = config.hosters[name] || {};
|
|
const hs = hosterSettings[name] || {};
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'hoster-settings-panel';
|
|
|
|
let credsHtml = '';
|
|
if (name === 'vidmoly.me') {
|
|
credsHtml = `
|
|
<div class="settings-row">
|
|
<label>Username</label>
|
|
<input type="text" class="key-input" data-hoster="${name}" data-field="username" value="${escapeAttr(hoster.username || '')}" placeholder="Username">
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Passwort</label>
|
|
<input type="password" class="key-input" data-hoster="${name}" data-field="password" value="${escapeAttr(hoster.password || '')}" placeholder="Passwort">
|
|
<button class="toggle-vis" title="Anzeigen">👁</button>
|
|
</div>`;
|
|
} else {
|
|
credsHtml = `
|
|
<div class="settings-row">
|
|
<label>API Key</label>
|
|
<input type="password" class="key-input" data-hoster="${name}" data-field="apiKey" value="${escapeAttr(hoster.apiKey || '')}" placeholder="API Key">
|
|
<button class="toggle-vis" title="Anzeigen">👁</button>
|
|
</div>`;
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
<div class="hoster-panel-header" data-hoster="${name}">
|
|
<span class="panel-arrow">▶</span>
|
|
<span class="panel-title">${name}</span>
|
|
<span class="panel-status ${hosterHasCredentials(name, hoster) ? 'active' : 'inactive'}">${hosterHasCredentials(name, hoster) ? 'Aktiv' : 'Inaktiv'}</span>
|
|
</div>
|
|
<div class="hoster-panel-body" data-panel="${name}" style="display:none">
|
|
${credsHtml}
|
|
<div class="settings-divider"></div>
|
|
<h4>Upload Einstellungen</h4>
|
|
<div class="settings-grid-mini">
|
|
<div class="settings-row">
|
|
<label>Retries</label>
|
|
<input type="number" class="hs-input" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500">
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Max Speed (kB/s)</label>
|
|
<input type="number" class="hs-input" data-hoster="${name}" data-hs="maxSpeedKbs" value="${hs.maxSpeedKbs ?? 0}" min="0">
|
|
<span class="hint">0 = unlimited</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Parallele Uploads</label>
|
|
<input type="number" class="hs-input" data-hoster="${name}" data-hs="parallelCount" value="${hs.parallelCount ?? 2}" min="1" max="10">
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Restart unter (kB/s)</label>
|
|
<input type="number" class="hs-input" data-hoster="${name}" data-hs="restartBelowKbs" value="${hs.restartBelowKbs ?? 0}" min="0">
|
|
<span class="hint">0 = off</span>
|
|
</div>
|
|
<div class="settings-row">
|
|
<label>Intervall (s)</label>
|
|
<input type="number" class="hs-input" 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" data-hoster="${name}" data-hs="maxSizeMb" value="${hs.maxSizeMb ?? 0}" min="0">
|
|
<span class="hint">0 = unlimited</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 ? '▶' : '▼';
|
|
});
|
|
|
|
// Toggle visibility
|
|
panel.querySelectorAll('.toggle-vis').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const input = btn.previousElementSibling;
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
const hosters = {};
|
|
const newHosterSettings = {};
|
|
|
|
for (const name of HOSTERS) {
|
|
// Credentials
|
|
if (name === 'vidmoly.me') {
|
|
const username = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`)?.value || '').trim();
|
|
const password = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`)?.value || '').trim();
|
|
hosters[name] = { enabled: !!(username && password), authType: 'login', username, password };
|
|
} else {
|
|
const apiKey = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="apiKey"]`)?.value || '').trim();
|
|
hosters[name] = { enabled: !!apiKey, apiKey };
|
|
}
|
|
|
|
// Upload settings
|
|
const hs = {};
|
|
document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => {
|
|
const field = input.dataset.hs;
|
|
hs[field] = parseInt(input.value) || 0;
|
|
});
|
|
newHosterSettings[name] = hs;
|
|
}
|
|
|
|
await window.api.saveConfig({ hosters });
|
|
await window.api.saveHosterSettings(newHosterSettings);
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
renderHosterChips();
|
|
renderSettings();
|
|
renderHealthCheckResults([]);
|
|
|
|
const feedback = document.getElementById('saveFeedback');
|
|
feedback.textContent = 'Gespeichert!';
|
|
setTimeout(() => { feedback.textContent = ''; }, 2000);
|
|
}
|
|
|
|
// --- 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 || [])) {
|
|
historyRowsData.push({
|
|
date: dt.text, dateTs: dt.ts,
|
|
filename: file.name || '', host: result.hoster || '',
|
|
link: result.status === 'error' ? `[Fehler] ${result.error || ''}` : (result.download_url || result.embed_url || ''),
|
|
isError: result.status === 'error', order: order++
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
renderHistoryTable(container);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
// --- Setup Listeners ---
|
|
function setupListeners() {
|
|
document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
|
|
document.getElementById('startUploadBtn').addEventListener('click', startUpload);
|
|
document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload);
|
|
document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck);
|
|
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('clearQueueBtn').addEventListener('click', () => {
|
|
if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); }
|
|
});
|
|
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
|
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
|
if (!confirm('Verlauf wirklich loeschen?')) 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 {}
|
|
});
|
|
}
|
|
|
|
// 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';
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
// --- 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} verfuegbar`;
|
|
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);
|
|
}
|
|
|
|
// --- Start ---
|
|
init();
|