New "Accounts" tab for managing hoster credentials separately from upload settings. Accounts can be added, edited, and deleted via modal dialogs. Login credentials are automatically verified on save, showing status (Bereit/Fehler) in the account list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1634 lines
61 KiB
JavaScript
1634 lines
61 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'|'error'|'checking'|'unchecked', message: '' } }
|
|
let editingAccountHoster = null; // null = adding, string = editing
|
|
let autoHealthCheckEnabled = true;
|
|
let queuePersistTimer = null;
|
|
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' };
|
|
|
|
// Session-specific files for the "Files" panel (resets each session)
|
|
let sessionFilesData = [];
|
|
|
|
// --- Init ---
|
|
async function init() {
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
autoHealthCheckEnabled = loadAutoCheckPreference();
|
|
syncSelectedUploadHosters();
|
|
restoreQueueStateFromConfig();
|
|
renderHosterSummary();
|
|
renderHosterModal();
|
|
renderSettings();
|
|
renderAccounts();
|
|
setupListeners();
|
|
setupDragDrop();
|
|
loadHistory();
|
|
renderRecentUploadsPanel();
|
|
updateUploadView();
|
|
|
|
if (shouldAutoResumeQueue()) {
|
|
setTimeout(() => {
|
|
if (!uploading) startUpload();
|
|
}, 350);
|
|
}
|
|
|
|
// 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 selection ---
|
|
function hosterHasCredentials(name, hoster) {
|
|
if (name === 'vidmoly.me') return !!(hoster.username && hoster.password);
|
|
if (name === 'voe.sx') 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 renderHosterSummary() {
|
|
const summary = document.getElementById('hosterSummary');
|
|
if (!summary) return;
|
|
const hosters = getSelectedHosters();
|
|
if (hosters.length === 0) {
|
|
summary.textContent = 'Keine Upload-Ziele ausgewaehlt';
|
|
} else if (hosters.length === 1) {
|
|
summary.textContent = `Aktives Ziel: ${hosters[0]}`;
|
|
} else {
|
|
summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.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 Einstellungen API-Key oder Login eintragen.';
|
|
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 === '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(item.name)}</div>
|
|
<div class="hoster-option-subtitle">${subtitle}</div>
|
|
</div>
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
|
|
hint.textContent = 'Die Auswahl wird fuer neue Queue-Eintraege 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);
|
|
renderHosterSummary();
|
|
if (!uploading && selectedFiles.length > 0) buildQueuePreview();
|
|
updateStartButton();
|
|
persistQueueStateSoon();
|
|
closeHosterModal();
|
|
}
|
|
|
|
function normalizeRestoredJobStatus(status) {
|
|
if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview') return status;
|
|
return 'queued';
|
|
}
|
|
|
|
function restoreQueueStateFromConfig() {
|
|
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 unfinishedJobs = queueJobs.filter(job => job.status !== 'done' && job.status !== 'skipped');
|
|
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
|
|
|
|
for (const job of unfinishedJobs) {
|
|
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 => job.status === 'done' || job.status === 'skipped')) {
|
|
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,
|
|
status: job.status,
|
|
bytesTotal: job.bytesTotal || 0,
|
|
error: 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() {
|
|
clearTimeout(queuePersistTimer);
|
|
queuePersistTimer = setTimeout(() => {
|
|
persistQueueStateNow().catch(() => {});
|
|
}, 250);
|
|
}
|
|
|
|
function clearPersistedQueueStateSoon() {
|
|
clearTimeout(queuePersistTimer);
|
|
queuePersistTimer = setTimeout(() => {
|
|
const globalSettings = {
|
|
...(config.globalSettings || {}),
|
|
pendingQueue: null
|
|
};
|
|
config.globalSettings = globalSettings;
|
|
window.api.saveGlobalSettings(globalSettings).catch(() => {});
|
|
}, 0);
|
|
}
|
|
|
|
function shouldAutoResumeQueue() {
|
|
if (!config?.globalSettings?.resumeQueueOnLaunch) return false;
|
|
return queueJobs.some(job => !['done', 'skipped'].includes(job.status));
|
|
}
|
|
|
|
// --- 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) {
|
|
let added = 0;
|
|
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 });
|
|
added++;
|
|
}
|
|
}
|
|
updateUploadView();
|
|
persistQueueStateSoon();
|
|
if (added > 0) openHosterModal();
|
|
}
|
|
|
|
async function pickFiles() {
|
|
const paths = await window.api.selectFiles();
|
|
if (!paths) return;
|
|
let added = 0;
|
|
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
|
|
added++;
|
|
}
|
|
}
|
|
updateUploadView();
|
|
persistQueueStateSoon();
|
|
if (added > 0) 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();
|
|
}
|
|
}
|
|
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();
|
|
if (hosters.length === 0) {
|
|
queueJobs = queueJobs.filter(j => j.status !== 'preview');
|
|
renderQueueTable();
|
|
persistQueueStateSoon();
|
|
return;
|
|
}
|
|
// 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();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
// --- 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();
|
|
closeHosterModal();
|
|
}
|
|
});
|
|
|
|
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(); }
|
|
persistQueueStateSoon();
|
|
} else if (action === 'copy-all-links') {
|
|
copyAllLinks();
|
|
} 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' || name === 'voe.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;
|
|
// 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);
|
|
persistQueueStateSoon();
|
|
|
|
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();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
// --- 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();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
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();
|
|
|
|
// Add completed jobs to session files panel
|
|
const dt = formatDateTime(new Date());
|
|
for (const job of queueJobs) {
|
|
if (job.status === 'done' && job.result) {
|
|
const link = job.result.download_url || job.result.embed_url || '';
|
|
if (link && !sessionFilesData.some(s => s.link === link && s.filename === job.fileName && s.host === job.hoster)) {
|
|
sessionFilesData.push({
|
|
date: dt.text, dateTs: dt.ts,
|
|
filename: job.fileName || '', host: job.hoster || '',
|
|
link, isError: false, order: sessionFilesData.length
|
|
});
|
|
}
|
|
} else if (job.status === 'error') {
|
|
sessionFilesData.push({
|
|
date: dt.text, dateTs: dt.ts,
|
|
filename: job.fileName || '', host: job.hoster || '',
|
|
link: `[Fehler] ${job.error || ''}`, isError: true, order: sessionFilesData.length
|
|
});
|
|
}
|
|
}
|
|
renderRecentUploadsPanel();
|
|
|
|
loadHistory();
|
|
clearPersistedQueueStateSoon();
|
|
|
|
// 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();
|
|
persistQueueStateSoon();
|
|
}
|
|
|
|
// --- 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' || n === 'voe.sx');
|
|
if (hosters.length === 0) {
|
|
const allHosters = ['doodstream.com', 'vidmoly.me', 'voe.sx'].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 = '';
|
|
|
|
const globalSettings = config.globalSettings || {};
|
|
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-row">
|
|
<label>FileUploader Log</label>
|
|
<input type="text" class="key-input" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
|
|
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner waehlen</button>
|
|
</div>
|
|
<div class="settings-row checkbox-row">
|
|
<label>Queue nach Neustart fortsetzen</label>
|
|
<input type="checkbox" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(generalPanel);
|
|
|
|
for (const name of HOSTERS) {
|
|
const hoster = config.hosters[name] || {};
|
|
const hs = hosterSettings[name] || {};
|
|
|
|
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">${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">
|
|
<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 ? '▶' : '▼';
|
|
});
|
|
}
|
|
|
|
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
async function saveSettings() {
|
|
const newHosterSettings = {};
|
|
const globalSettings = {
|
|
...(config.globalSettings || {}),
|
|
logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(),
|
|
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked
|
|
};
|
|
|
|
for (const name of HOSTERS) {
|
|
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.saveHosterSettings(newHosterSettings);
|
|
await window.api.saveGlobalSettings(globalSettings);
|
|
config = await window.api.getConfig();
|
|
hosterSettings = config.hosterSettings || {};
|
|
renderSettings();
|
|
|
|
const feedback = document.getElementById('saveFeedback');
|
|
feedback.textContent = 'Gespeichert!';
|
|
setTimeout(() => { feedback.textContent = ''; }, 2000);
|
|
}
|
|
|
|
// --- 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 hoster.username || 'Login';
|
|
if (name === 'voe.sx') return hoster.username && hoster.password ? (hoster.username || 'Login') : 'API-Key';
|
|
return 'API-Key';
|
|
}
|
|
|
|
function renderAccounts() {
|
|
const container = document.getElementById('accountsList');
|
|
if (!container) return;
|
|
|
|
const accounts = getAccountsWithCreds();
|
|
|
|
if (accounts.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="accounts-empty">
|
|
<p>Keine Accounts vorhanden</p>
|
|
<span class="hint">Klicke auf "Account hinzufuegen" um einen Hoster einzurichten.</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = accounts.map(({ name, hoster }) => {
|
|
const st = accountStatuses[name] || { status: 'unchecked', message: '' };
|
|
const statusLabels = { ok: 'Bereit', checking: 'Pruefe...', error: 'Fehler', unchecked: 'Nicht geprueft' };
|
|
const statusLabel = statusLabels[st.status] || 'Nicht geprueft';
|
|
const credLabel = getCredentialLabel(name, hoster);
|
|
|
|
return `
|
|
<div class="account-card" data-account="${name}">
|
|
<div class="account-card-info">
|
|
<div class="account-card-title">${escapeHtml(name)}</div>
|
|
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.status === 'error' && 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}">Pruefen</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}">Loeschen</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) {
|
|
accountStatuses[hosterName] = { status: 'checking', message: '' };
|
|
renderAccounts();
|
|
try {
|
|
const rows = await executeHealthCheck([hosterName], 'auto');
|
|
const row = rows.find(r => r.hoster === hosterName);
|
|
if (row && row.status === 'ok') {
|
|
accountStatuses[hosterName] = { status: 'ok', message: row.message || '' };
|
|
} else {
|
|
accountStatuses[hosterName] = { status: 'error', message: (row && row.message) || 'Pruefung fehlgeschlagen' };
|
|
}
|
|
} catch (err) {
|
|
accountStatuses[hosterName] = { status: 'error', message: err.message || 'Pruefung fehlgeschlagen' };
|
|
}
|
|
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') {
|
|
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 fuer 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 fuer 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 fuer ${editingAccountHoster} bearbeiten.`;
|
|
hosterRow.style.display = 'none';
|
|
saveBtn.textContent = 'Speichern & Pruefen';
|
|
const hoster = config.hosters[editingAccountHoster] || {};
|
|
credsContainer.innerHTML = getCredsFieldsHtml(editingAccountHoster, hoster);
|
|
} else {
|
|
// Add mode
|
|
title.textContent = 'Account hinzufuegen';
|
|
subtitle.textContent = 'Waehle einen Hoster und gib deine Zugangsdaten ein.';
|
|
hosterRow.style.display = 'flex';
|
|
saveBtn.textContent = 'Anlegen & Pruefen';
|
|
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}">${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 fuer "${hosterName}" wirklich loeschen? 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') {
|
|
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();
|
|
syncSelectedUploadHosters();
|
|
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') {
|
|
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 = 'Pruefe 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') {
|
|
accountStatuses[hosterName] = { status: 'ok', message: row.message || '' };
|
|
statusEl.textContent = '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 || 'Pruefung fehlgeschlagen' };
|
|
statusEl.textContent = err.message || 'Pruefung fehlgeschlagen';
|
|
statusEl.className = 'account-modal-status error';
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
renderAccounts();
|
|
}
|
|
}
|
|
|
|
// --- 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 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 = sessionFilesData
|
|
.slice()
|
|
.sort((a, b) => b.dateTs - a.dateTs || b.order - a.order)
|
|
.slice(0, 20);
|
|
|
|
tbody.innerHTML = rows.map(row => `
|
|
<tr class="recent-file-row${row.isError ? ' error' : ''}" 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(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 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('chooseHostersBtn').addEventListener('click', openHosterModal);
|
|
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('confirmHosterModalBtn').addEventListener('click', applyHosterSelection);
|
|
document.getElementById('cancelHosterModalBtn').addEventListener('click', closeHosterModal);
|
|
document.getElementById('closeHosterModalBtn').addEventListener('click', closeHosterModal);
|
|
document.getElementById('selectAllHostersBtn').addEventListener('click', () => {
|
|
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
|
input.checked = true;
|
|
input.closest('.hoster-option')?.classList.add('selected');
|
|
});
|
|
});
|
|
document.getElementById('clearHostersBtn').addEventListener('click', () => {
|
|
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
|
|
input.checked = false;
|
|
input.closest('.hoster-option')?.classList.remove('selected');
|
|
});
|
|
});
|
|
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
|
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
|
if (!confirm('Verlauf wirklich 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);
|
|
});
|
|
|
|
document.getElementById('hosterModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'hosterModal') closeHosterModal();
|
|
});
|
|
|
|
// 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} 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();
|