Multi-Hoster-Upload/main.js
Administrator 25b2afbf11 feat: add queue system, per-hoster settings, retry logic, and full UI overhaul
- Add FIFO semaphore for per-hoster concurrency control
- Add token-bucket speed limiter with abort signal support
- Rewrite upload-manager with retry loop, speed monitoring, and rich progress events
- Add per-hoster settings: retries, max speed, parallel count, restart below speed, time interval, max size
- Add context menu with shutdown-after-finish (sleep/shutdown/restart), always-on-top
- Add z-o-o-m-style queue table with 8 columns, status-colored rows, progress bars
- Add debounced queue rendering with scroll position preservation
- Add statusbar with global speed, total bytes, elapsed time
- Fix speedMonitor interval leak on error and scoping bug
- Fix throttle not respecting abort signal during cancellation
- Fix combined signal listener cleanup
- Bump version to 1.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 05:57:00 +01:00

463 lines
13 KiB
JavaScript

const { app, BrowserWindow, ipcMain, dialog, clipboard } = require('electron');
const path = require('path');
const fs = require('fs');
const ConfigStore = require('./lib/config-store');
const UploadManager = require('./lib/upload-manager');
const { HOSTER_CONFIGS } = require('./lib/hosters');
const VidmolyUploader = require('./lib/vidmoly-upload');
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
let mainWindow;
const configStore = new ConfigStore(app);
let uploadManager = null;
const HEALTH_CHECK_TIMEOUT = 25000;
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} Timeout`));
}, timeoutMs);
promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
function normalizeApiError(payload, fallback) {
if (!payload || typeof payload !== 'object') return fallback;
const msg = String(payload.msg || payload.message || '').trim();
if (msg) return msg;
if (payload.status) return `API Status ${payload.status}`;
return fallback;
}
function getLogFilePath() {
// Next to the EXE when packaged, next to project root when dev
const baseDir = app.isPackaged
? path.dirname(process.execPath)
: path.join(__dirname);
return path.join(baseDir, 'fileuploader.log');
}
function appendUploadLog(hoster, link, fileName) {
try {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
const line = `${dateStr}|${hoster}|${link}||${fileName}|\n`;
fs.appendFileSync(getLogFilePath(), line, 'utf-8');
} catch {}
}
async function checkDoodstreamHealth(hosterConfig) {
const apiKey = hosterConfig && hosterConfig.apiKey
? String(hosterConfig.apiKey).trim()
: '';
if (!apiKey) {
return { status: 'error', message: 'API Key fehlt' };
}
const apiBase = HOSTER_CONFIGS['doodstream.com'].apiBase;
const accountRes = await fetch(`${apiBase}/api/account/info?key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
redirect: 'follow'
});
const accountPayload = await accountRes.json().catch(() => null);
if (!accountPayload || typeof accountPayload !== 'object') {
return { status: 'error', message: 'Account-Check lieferte kein gueltiges JSON' };
}
if (Number(accountPayload.status || 0) !== 200) {
return {
status: 'error',
message: normalizeApiError(accountPayload, 'Account-Check fehlgeschlagen')
};
}
const serverRes = await fetch(`${apiBase}/api/upload/server?key=${encodeURIComponent(apiKey)}`, {
method: 'GET',
redirect: 'follow'
});
const serverPayload = await serverRes.json().catch(() => null);
if (!serverPayload || typeof serverPayload !== 'object') {
return { status: 'warn', message: 'Upload-Server-Check lieferte kein gueltiges JSON' };
}
const serverResult = serverPayload.result;
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
}
const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim();
if (/no servers available/i.test(serverMsg)) {
return {
status: 'warn',
message: 'API Key gueltig, aktuell kein Server von API (Uploader nutzt Fallback)'
};
}
return {
status: 'warn',
message: serverMsg || 'API Key gueltig, Upload-Server aktuell nicht geliefert'
};
}
async function checkVidmolyHealth(hosterConfig) {
const username = hosterConfig && hosterConfig.username
? String(hosterConfig.username).trim()
: '';
const password = hosterConfig && hosterConfig.password
? String(hosterConfig.password).trim()
: '';
if (!username || !password) {
return { status: 'error', message: 'Username oder Passwort fehlt' };
}
const uploader = new VidmolyUploader();
await uploader.login(username, password);
const { uploadUrl, fileFieldName } = await uploader.getUploadParams();
if (!uploadUrl || !/^https?:\/\//i.test(uploadUrl)) {
return { status: 'error', message: 'Upload-URL wurde nicht erkannt' };
}
return {
status: 'ok',
message: `Login ok, Upload-Form bereit (Dateifeld: ${fileFieldName || 'file'})`
};
}
async function runHosterHealthCheck(config, requestedHosters) {
const allowed = ['doodstream.com', 'vidmoly.me'];
const source = Array.isArray(requestedHosters) && requestedHosters.length > 0
? requestedHosters
: allowed;
const hosters = source
.map((name) => String(name || '').trim())
.filter((name, index, arr) => name && arr.indexOf(name) === index);
const checks = hosters.map(async (hoster) => {
if (!allowed.includes(hoster)) {
return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
}
const hosterConfig = config && config.hosters ? config.hosters[hoster] : null;
try {
if (hoster === 'doodstream.com') {
const result = await withTimeout(
checkDoodstreamHealth(hosterConfig),
HEALTH_CHECK_TIMEOUT,
'Doodstream-Check'
);
return { hoster, ...result };
}
if (hoster === 'vidmoly.me') {
const result = await withTimeout(
checkVidmolyHealth(hosterConfig),
HEALTH_CHECK_TIMEOUT,
'Vidmoly-Check'
);
return { hoster, ...result };
}
return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
} catch (err) {
return {
hoster,
status: 'error',
message: err && err.message ? err.message : 'Health-Check fehlgeschlagen'
};
}
});
const results = await Promise.all(checks);
return {
checkedAt: new Date().toISOString(),
results
};
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 750,
minWidth: 800,
minHeight: 550,
backgroundColor: '#0f0f1a',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}
app.whenReady().then(() => {
createWindow();
// Auto-check for updates after 3 seconds
setTimeout(async () => {
try {
const result = await checkForUpdate();
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-available', result);
}
} catch {}
}, 3000);
});
app.on('window-all-closed', () => {
app.quit();
});
// --- IPC Handlers ---
ipcMain.handle('get-config', () => {
return configStore.load();
});
ipcMain.handle('save-config', (_event, config) => {
configStore.save(config);
return true;
});
ipcMain.handle('get-history', () => {
return configStore.loadHistory();
});
ipcMain.handle('run-health-check', async (_event, payload) => {
const config = configStore.load();
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
return runHosterHealthCheck(config, hosters);
});
ipcMain.handle('select-files', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Alle Dateien', extensions: ['*'] },
{ name: 'Videos', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm'] }
]
});
return result.canceled ? null : result.filePaths;
});
ipcMain.handle('start-upload', (_event, payload) => {
const config = configStore.load();
const { files, hosters } = payload;
// Build tasks with credentials
const tasks = [];
for (const file of files) {
for (const hoster of hosters) {
const hosterConfig = config.hosters[hoster];
if (!hosterConfig) continue;
if (hoster === 'vidmoly.me') {
// Vidmoly uses username/password login
if (!hosterConfig.username || !hosterConfig.password) continue;
tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password });
} else {
// Other hosters use API key
if (!hosterConfig.apiKey) continue;
tasks.push({ file, hoster, apiKey: hosterConfig.apiKey });
}
}
}
if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' };
// Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {});
uploadManager.on('progress', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-progress', data);
}
});
uploadManager.on('stats', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-stats', data);
}
});
uploadManager.on('batch-done', (summary) => {
configStore.appendHistory(summary);
// Write successful uploads to fileuploader.log
for (const file of summary.files || []) {
for (const result of file.results || []) {
if (result.status === 'done' && (result.download_url || result.embed_url)) {
appendUploadLog(
result.hoster || '',
result.download_url || result.embed_url || '',
file.name || ''
);
}
}
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
// Shutdown after finish
handleShutdownAfterFinish();
});
uploadManager.startBatch(tasks);
return { started: true, taskCount: tasks.length };
});
ipcMain.handle('cancel-upload', () => {
if (uploadManager) {
uploadManager.cancel();
uploadManager = null;
}
return true;
});
ipcMain.handle('clear-history', () => {
configStore.clearHistory();
return true;
});
ipcMain.handle('copy-to-clipboard', (_event, text) => {
clipboard.writeText(text);
return true;
});
ipcMain.handle('app:check-updates', async () => {
try {
return await checkForUpdate();
} catch (err) {
return { available: false, error: err.message };
}
});
ipcMain.handle('app:install-update', async () => {
try {
installUpdate((progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', progress);
}
});
return { started: true };
} catch (err) {
return { error: err.message };
}
});
ipcMain.handle('app:abort-update', () => {
abortUpdate();
return true;
});
ipcMain.handle('app:get-version', () => {
return app.getVersion();
});
// --- Hoster settings ---
ipcMain.handle('get-hoster-settings', () => {
const config = configStore.load();
return config.hosterSettings || {};
});
ipcMain.handle('save-hoster-settings', (_event, hosterSettings) => {
configStore.save({ hosterSettings });
return true;
});
// --- Global settings ---
ipcMain.handle('get-global-settings', () => {
const config = configStore.load();
return config.globalSettings || {};
});
ipcMain.handle('save-global-settings', (_event, globalSettings) => {
configStore.save({ globalSettings });
return true;
});
// --- Always on top ---
ipcMain.handle('set-always-on-top', (_event, value) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setAlwaysOnTop(!!value);
}
configStore.save({ globalSettings: { ...configStore.load().globalSettings, alwaysOnTop: !!value } });
return true;
});
ipcMain.handle('get-always-on-top', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow.isAlwaysOnTop();
}
return false;
});
// --- Shutdown after finish ---
let shutdownMode = 'nothing';
let shutdownTimer = null;
ipcMain.handle('set-shutdown-after-finish', (_event, mode) => {
shutdownMode = mode || 'nothing';
return true;
});
ipcMain.handle('get-shutdown-after-finish', () => {
return shutdownMode;
});
ipcMain.handle('cancel-shutdown', () => {
if (shutdownTimer) {
clearTimeout(shutdownTimer);
shutdownTimer = null;
}
shutdownMode = 'nothing';
return true;
});
function handleShutdownAfterFinish() {
if (shutdownMode === 'nothing') return;
const { exec } = require('child_process');
const mode = shutdownMode;
// Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shutdown-countdown', { mode, seconds: 60 });
}
shutdownTimer = setTimeout(() => {
if (mode === 'shutdown') {
exec('shutdown /s /t 0');
} else if (mode === 'restart') {
exec('shutdown /r /t 0');
} else if (mode === 'sleep') {
exec('rundll32.exe powrprof.dll,SetSuspendState 0,1,0');
}
}, 60000);
}
// Restore always-on-top from config on window creation
app.on('browser-window-created', () => {
const config = configStore.load();
if (config.globalSettings && config.globalSettings.alwaysOnTop && mainWindow) {
mainWindow.setAlwaysOnTop(true);
}
});