diff --git a/lib/config-store.js b/lib/config-store.js
index d9f64eb..7009f2a 100644
--- a/lib/config-store.js
+++ b/lib/config-store.js
@@ -1,6 +1,15 @@
const fs = require('fs');
const path = require('path');
+const HOSTER_SETTINGS_DEFAULTS = {
+ retries: 3,
+ maxSpeedKbs: 0, // 0 = unlimited
+ parallelCount: 2, // 1-10
+ restartBelowKbs: 0, // 0 = off
+ timeIntervalSec: 0, // delay between jobs
+ maxSizeMb: 0 // 0 = unlimited
+};
+
const DEFAULTS = {
hosters: {
'doodstream.com': { enabled: true, apiKey: '' },
@@ -8,6 +17,16 @@ const DEFAULTS = {
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
'byse.sx': { enabled: true, apiKey: '' }
},
+ hosterSettings: {
+ 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
+ 'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
+ 'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
+ 'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS }
+ },
+ globalSettings: {
+ alwaysOnTop: false,
+ shutdownAfterFinish: 'nothing' // nothing | sleep | shutdown | restart
+ },
history: []
};
@@ -32,7 +51,19 @@ class ConfigStore {
hosters[name] = { ...hosters[name], ...val };
}
}
- return { hosters, history: data.history || [] };
+ // Merge hoster settings with defaults
+ const hosterSettings = {};
+ for (const name of Object.keys(DEFAULTS.hosterSettings)) {
+ hosterSettings[name] = {
+ ...HOSTER_SETTINGS_DEFAULTS,
+ ...(data.hosterSettings && data.hosterSettings[name] || {})
+ };
+ }
+ const globalSettings = {
+ ...DEFAULTS.globalSettings,
+ ...(data.globalSettings || {})
+ };
+ return { hosters, hosterSettings, globalSettings, history: data.history || [] };
} catch {
return JSON.parse(JSON.stringify(DEFAULTS));
}
@@ -40,8 +71,12 @@ class ConfigStore {
save(config) {
const current = this.load();
- // Only update hosters, keep history
- current.hosters = config.hosters || current.hosters;
+ // Update hosters credentials
+ if (config.hosters) current.hosters = config.hosters;
+ // Update hoster settings
+ if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
+ // Update global settings
+ if (config.globalSettings) current.globalSettings = config.globalSettings;
fs.writeFileSync(this.filePath, JSON.stringify(current, null, 2), 'utf-8');
}
diff --git a/lib/hosters.js b/lib/hosters.js
index e8e21d0..c7ae07b 100644
--- a/lib/hosters.js
+++ b/lib/hosters.js
@@ -238,7 +238,7 @@ function buildMultipart(filePath, formFields) {
return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize };
}
-function createUploadBody(filePath, formFields, onProgress) {
+function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
let bytesRead = 0;
@@ -248,6 +248,7 @@ function createUploadBody(filePath, formFields, onProgress) {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) {
+ if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length;
yield chunk;
if (onProgress) onProgress(bytesRead, fileSize);
@@ -337,7 +338,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
}
-async function uploadFile(hosterName, filePath, apiKey, onProgress, signal) {
+async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
@@ -348,7 +349,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal) {
const targetUrl = config.buildUploadUrl(uploadUrl, apiKey);
const formFields = config.formFields(apiKey);
- const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress);
+ const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal);
const { body, statusCode } = await request(targetUrl, {
method: 'POST',
diff --git a/lib/semaphore.js b/lib/semaphore.js
new file mode 100644
index 0000000..6f58688
--- /dev/null
+++ b/lib/semaphore.js
@@ -0,0 +1,48 @@
+/**
+ * FIFO Semaphore for per-hoster concurrency control.
+ * acquire() blocks until a slot is available, release() frees it.
+ */
+class Semaphore {
+ constructor(limit) {
+ this.limit = Math.max(1, limit || 1);
+ this.active = 0;
+ this.queue = [];
+ }
+
+ acquire() {
+ return new Promise((resolve) => {
+ if (this.active < this.limit) {
+ this.active++;
+ resolve();
+ } else {
+ this.queue.push(resolve);
+ }
+ });
+ }
+
+ release() {
+ if (this.queue.length > 0) {
+ // Don't decrement active — hand slot directly to next waiter
+ const next = this.queue.shift();
+ next();
+ } else {
+ this.active = Math.max(0, this.active - 1);
+ }
+ }
+
+ updateLimit(newLimit) {
+ this.limit = Math.max(1, newLimit || 1);
+ // If new limit is higher, wake up waiting tasks
+ while (this.active < this.limit && this.queue.length > 0) {
+ this.active++;
+ const next = this.queue.shift();
+ next();
+ }
+ }
+
+ get pending() {
+ return this.queue.length;
+ }
+}
+
+module.exports = Semaphore;
diff --git a/lib/throttle.js b/lib/throttle.js
new file mode 100644
index 0000000..c7bc2ac
--- /dev/null
+++ b/lib/throttle.js
@@ -0,0 +1,42 @@
+/**
+ * Token-bucket speed limiter for bandwidth throttling.
+ * maxBytesPerSec = 0 means unlimited (passthrough).
+ */
+class Throttle {
+ constructor(maxBytesPerSec) {
+ this.maxBps = maxBytesPerSec || 0;
+ this.tokens = this.maxBps;
+ this.lastRefill = Date.now();
+ }
+
+ async consume(bytes, signal) {
+ if (this.maxBps <= 0) return; // unlimited
+
+ while (bytes > 0) {
+ if (signal && signal.aborted) return;
+ this._refill();
+ const available = Math.min(bytes, Math.floor(this.tokens));
+ if (available > 0) {
+ this.tokens -= available;
+ bytes -= available;
+ }
+ if (bytes > 0) {
+ // Wait 50ms for tokens to refill
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ }
+ }
+
+ _refill() {
+ const now = Date.now();
+ const elapsed = (now - this.lastRefill) / 1000;
+ this.tokens = Math.min(this.maxBps, this.tokens + elapsed * this.maxBps);
+ this.lastRefill = now;
+ }
+
+ updateRate(maxBytesPerSec) {
+ this.maxBps = maxBytesPerSec || 0;
+ }
+}
+
+module.exports = Throttle;
diff --git a/lib/upload-manager.js b/lib/upload-manager.js
index e4b60ec..2ff544c 100644
--- a/lib/upload-manager.js
+++ b/lib/upload-manager.js
@@ -4,21 +4,53 @@ const fs = require('fs');
const crypto = require('crypto');
const { uploadFile } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload');
+const Semaphore = require('./semaphore');
+const Throttle = require('./throttle');
+
+const DEFAULT_SETTINGS = {
+ retries: 3,
+ maxSpeedKbs: 0,
+ parallelCount: 2,
+ restartBelowKbs: 0,
+ timeIntervalSec: 0,
+ maxSizeMb: 0
+};
class UploadManager extends EventEmitter {
- constructor() {
+ constructor(hosterSettings) {
super();
+ this.hosterSettings = hosterSettings || {};
+ this.semaphores = {};
this.abortController = new AbortController();
this.running = false;
+ this.statsInterval = null;
+ this.startTime = 0;
+ this.activeJobs = new Map(); // uploadId -> { speedKbs, bytesUploaded }
+ this.sessionBytes = 0;
+ }
+
+ _getSettings(hoster) {
+ return { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
+ }
+
+ _getSemaphore(hoster) {
+ if (!this.semaphores[hoster]) {
+ const settings = this._getSettings(hoster);
+ this.semaphores[hoster] = new Semaphore(settings.parallelCount);
+ }
+ return this.semaphores[hoster];
}
async startBatch(tasks) {
this.running = true;
this.abortController = new AbortController();
+ this.startTime = Date.now();
+ this.sessionBytes = 0;
+ this.activeJobs.clear();
const { signal } = this.abortController;
const batchId = `batch-${Date.now()}`;
- const results = new Map(); // fileName -> { name, size, results: [] }
+ const results = new Map(); // filePath -> { name, size, results: [] }
// Initialize result map per file
for (const task of tasks) {
@@ -30,96 +62,14 @@ class UploadManager extends EventEmitter {
}
}
- // Build upload promises
- const promises = tasks.map(async (task) => {
- const uploadId = crypto.randomBytes(8).toString('hex');
- const fileName = path.basename(task.file);
- let fileSize = 0;
- try { fileSize = fs.statSync(task.file).size; } catch {}
-
- // Emit initial status
- this.emit('progress', {
- uploadId,
- fileName,
- hoster: task.hoster,
- status: 'getting-server',
- progress: 0,
- bytesUploaded: 0,
- bytesTotal: fileSize,
- error: null,
- result: null
- });
-
- try {
- let result;
- const progressCb = (bytesUploaded, bytesTotal) => {
- this.emit('progress', {
- uploadId,
- fileName,
- hoster: task.hoster,
- status: 'uploading',
- progress: bytesTotal > 0 ? bytesUploaded / bytesTotal : 0,
- bytesUploaded,
- bytesTotal,
- error: null,
- result: null
- });
- };
-
- if (task.hoster === 'vidmoly.me' && task.username) {
- // Vidmoly: login-based upload
- const vidmoly = new VidmolyUploader();
- await vidmoly.login(task.username, task.password);
- result = await vidmoly.upload(task.file, progressCb, signal);
- } else {
- result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal);
- }
-
- this.emit('progress', {
- uploadId,
- fileName,
- hoster: task.hoster,
- status: 'done',
- progress: 1,
- bytesUploaded: fileSize,
- bytesTotal: fileSize,
- error: null,
- result
- });
-
- results.get(task.file).results.push({
- hoster: task.hoster,
- status: 'done',
- ...result
- });
-
- } catch (err) {
- const errorMsg = err.name === 'AbortError' ? 'Abgebrochen' : err.message;
-
- this.emit('progress', {
- uploadId,
- fileName,
- hoster: task.hoster,
- status: 'error',
- progress: 0,
- bytesUploaded: 0,
- bytesTotal: fileSize,
- error: errorMsg,
- result: null
- });
-
- results.get(task.file).results.push({
- hoster: task.hoster,
- status: 'error',
- error: errorMsg,
- download_url: null,
- embed_url: null,
- file_code: null
- });
- }
- });
+ // Start global stats emitter
+ this._startStatsTimer();
+ // Create job promises — semaphore controls concurrency per hoster
+ const promises = tasks.map((task) => this._runJob(task, results, signal));
await Promise.allSettled(promises);
+
+ this._stopStatsTimer();
this.running = false;
const files = Array.from(results.values());
@@ -138,10 +88,301 @@ class UploadManager extends EventEmitter {
this.emit('batch-done', summary);
}
+ async _runJob(task, results, signal) {
+ const settings = this._getSettings(task.hoster);
+ const semaphore = this._getSemaphore(task.hoster);
+ const uploadId = crypto.randomBytes(8).toString('hex');
+ const fileName = path.basename(task.file);
+ let fileSize = 0;
+ try { fileSize = fs.statSync(task.file).size; } catch {}
+
+ const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
+
+ // File size filter
+ if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
+ const errMsg = `Datei zu gross (Max: ${settings.maxSizeMb} MB)`;
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'skipped', progress: 0,
+ bytesUploaded: 0, bytesTotal: fileSize,
+ speedKbs: 0, elapsed: 0, remaining: 0,
+ error: errMsg, result: null, attempt: 0, maxAttempts
+ });
+ results.get(task.file).results.push({
+ hoster: task.hoster, status: 'error', error: errMsg,
+ download_url: null, embed_url: null, file_code: null
+ });
+ return;
+ }
+
+ // Emit queued status
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'queued', progress: 0,
+ bytesUploaded: 0, bytesTotal: fileSize,
+ speedKbs: 0, elapsed: 0, remaining: 0,
+ error: null, result: null, attempt: 0, maxAttempts
+ });
+
+ // Wait for semaphore slot
+ await semaphore.acquire();
+ if (signal.aborted) {
+ semaphore.release();
+ return;
+ }
+
+ // Time interval delay between jobs
+ if (settings.timeIntervalSec > 0) {
+ await this._sleep(settings.timeIntervalSec * 1000, signal).catch(() => {});
+ if (signal.aborted) {
+ semaphore.release();
+ return;
+ }
+ }
+
+ let lastError = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ if (signal.aborted) break;
+
+ // Retry delay
+ if (attempt > 1) {
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'retrying', progress: 0,
+ bytesUploaded: 0, bytesTotal: fileSize,
+ speedKbs: 0, elapsed: 0, remaining: 0,
+ error: lastError ? lastError.message : null,
+ result: null, attempt, maxAttempts
+ });
+ await this._sleep(2500, signal).catch(() => {});
+ if (signal.aborted) break;
+ }
+
+ const jobStart = Date.now();
+ let lastBytes = 0;
+ let lastSpeedTime = jobStart;
+ let currentSpeedKbs = 0;
+ let lowSpeedSince = 0;
+ let speedAbort = null;
+
+ // Register active job for global stats
+ this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 });
+
+ // Speed monitor interval (declared outside try for cleanup in catch)
+ let speedMonitor = null;
+
+ try {
+ // Getting server
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'getting-server', progress: 0,
+ bytesUploaded: 0, bytesTotal: fileSize,
+ speedKbs: 0, elapsed: 0, remaining: 0,
+ error: null, result: null, attempt, maxAttempts
+ });
+
+ // Create per-job throttle
+ const throttle = settings.maxSpeedKbs > 0
+ ? new Throttle(settings.maxSpeedKbs * 1024)
+ : null;
+
+ // Speed monitor: abort if too slow
+ if (settings.restartBelowKbs > 0) {
+ speedAbort = new AbortController();
+ }
+
+ // Combined signal
+ const jobSignal = speedAbort
+ ? this._combineSignals(signal, speedAbort.signal)
+ : signal;
+ if (settings.restartBelowKbs > 0) {
+ speedMonitor = setInterval(() => {
+ if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
+ if (!lowSpeedSince) lowSpeedSince = Date.now();
+ if (Date.now() - lowSpeedSince > 6000) {
+ if (speedAbort) speedAbort.abort();
+ clearInterval(speedMonitor);
+ }
+ } else {
+ lowSpeedSince = 0;
+ }
+ }, 2000);
+ }
+
+ // Progress callback with speed tracking
+ const progressCb = (bytesUploaded, bytesTotal) => {
+ const now = Date.now();
+ const elapsed = Math.round((now - jobStart) / 1000);
+
+ // Speed calculation (update every ~1s)
+ const timeDelta = (now - lastSpeedTime) / 1000;
+ if (timeDelta >= 1) {
+ const bytesDelta = bytesUploaded - lastBytes;
+ currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
+ lastBytes = bytesUploaded;
+ lastSpeedTime = now;
+ }
+
+ const remaining = currentSpeedKbs > 0
+ ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
+ : 0;
+
+ // Update active job stats for global aggregation
+ this.activeJobs.set(uploadId, { speedKbs: currentSpeedKbs, bytesUploaded });
+
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'uploading', progress: bytesTotal > 0 ? bytesUploaded / bytesTotal : 0,
+ bytesUploaded, bytesTotal: bytesTotal,
+ speedKbs: currentSpeedKbs, elapsed, remaining,
+ error: null, result: null, attempt, maxAttempts
+ });
+ };
+
+ let result;
+ if (task.hoster === 'vidmoly.me' && task.username) {
+ const vidmoly = new VidmolyUploader();
+ await vidmoly.login(task.username, task.password);
+ result = await vidmoly.upload(task.file, progressCb, jobSignal, throttle);
+ } else {
+ result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle);
+ }
+
+ // Clear speed monitor
+ if (speedMonitor) clearInterval(speedMonitor);
+
+ // Track session bytes
+ this.sessionBytes += fileSize;
+ this.activeJobs.delete(uploadId);
+
+ // Success
+ const elapsed = Math.round((Date.now() - jobStart) / 1000);
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'done', progress: 1,
+ bytesUploaded: fileSize, bytesTotal: fileSize,
+ speedKbs: currentSpeedKbs, elapsed, remaining: 0,
+ error: null, result, attempt, maxAttempts
+ });
+
+ results.get(task.file).results.push({
+ hoster: task.hoster, status: 'done', ...result
+ });
+
+ semaphore.release();
+ return; // Success — exit retry loop
+
+ } catch (err) {
+ // Clear speed monitor interval on error
+ if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; }
+ if (speedAbort) {
+ // Check if this was a speed restart
+ try { speedAbort.abort(); } catch {}
+ }
+ this.activeJobs.delete(uploadId);
+
+ if (signal.aborted) {
+ lastError = err;
+ break;
+ }
+
+ // Check if speed restart (not user abort)
+ const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
+ if (isSpeedRestart && attempt < maxAttempts) {
+ lastError = new Error('Geschwindigkeit zu niedrig - Neustart');
+ continue;
+ }
+
+ lastError = err;
+ if (attempt >= maxAttempts) break;
+ }
+ }
+
+ // All attempts exhausted
+ this.activeJobs.delete(uploadId);
+ const errorMsg = signal.aborted
+ ? 'Abgebrochen'
+ : (lastError ? lastError.message : 'Unbekannter Fehler');
+
+ this._emitProgress(uploadId, fileName, task.hoster, {
+ status: 'error', progress: 0,
+ bytesUploaded: 0, bytesTotal: fileSize,
+ speedKbs: 0, elapsed: 0, remaining: 0,
+ error: errorMsg, result: null,
+ attempt: maxAttempts, maxAttempts
+ });
+
+ results.get(task.file).results.push({
+ hoster: task.hoster, status: 'error', error: errorMsg,
+ download_url: null, embed_url: null, file_code: null
+ });
+
+ semaphore.release();
+ }
+
+ _emitProgress(uploadId, fileName, hoster, data) {
+ this.emit('progress', { uploadId, fileName, hoster, ...data });
+ }
+
+ _startStatsTimer() {
+ this.statsInterval = setInterval(() => {
+ let globalSpeedKbs = 0;
+ let activeCount = 0;
+ for (const job of this.activeJobs.values()) {
+ globalSpeedKbs += job.speedKbs || 0;
+ activeCount++;
+ }
+
+ const elapsed = Math.round((Date.now() - this.startTime) / 1000);
+
+ // Sum in-progress bytes for live total
+ let inProgressBytes = 0;
+ for (const job of this.activeJobs.values()) {
+ inProgressBytes += job.bytesUploaded || 0;
+ }
+
+ this.emit('stats', {
+ state: this.running ? 'uploading' : 'idle',
+ globalSpeedKbs,
+ totalBytes: this.sessionBytes + inProgressBytes,
+ elapsed,
+ activeJobs: activeCount,
+ pendingJobs: Object.values(this.semaphores).reduce((sum, s) => sum + s.pending, 0)
+ });
+ }, 1000);
+ }
+
+ _stopStatsTimer() {
+ if (this.statsInterval) {
+ clearInterval(this.statsInterval);
+ this.statsInterval = null;
+ }
+ }
+
+ _combineSignals(signal1, signal2) {
+ const controller = new AbortController();
+ if (signal1.aborted || signal2.aborted) { controller.abort(); return controller.signal; }
+ const cleanup = () => {
+ signal1.removeEventListener('abort', onAbort);
+ signal2.removeEventListener('abort', onAbort);
+ };
+ const onAbort = () => { controller.abort(); cleanup(); };
+ signal1.addEventListener('abort', onAbort, { once: true });
+ signal2.addEventListener('abort', onAbort, { once: true });
+ return controller.signal;
+ }
+
+ _sleep(ms, signal) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(resolve, ms);
+ if (signal) {
+ if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; }
+ signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }, { once: true });
+ }
+ });
+ }
+
cancel() {
if (this.running) {
this.abortController.abort();
this.running = false;
+ this._stopStatsTimer();
+ this.activeJobs.clear();
}
}
}
diff --git a/lib/vidmoly-upload.js b/lib/vidmoly-upload.js
index 15aa3b7..cfda9e0 100644
--- a/lib/vidmoly-upload.js
+++ b/lib/vidmoly-upload.js
@@ -169,7 +169,7 @@ class VidmolyUploader {
/**
* Upload a file to Vidmoly (uses undici.request for streaming progress)
*/
- async upload(filePath, onProgress, signal) {
+ async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureVmFileCodes();
@@ -210,6 +210,7 @@ class VidmolyUploader {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of fileStream) {
+ if (throttle) await throttle.consume(chunk.length, signal);
bytesRead += chunk.length;
yield chunk;
if (onProgress) onProgress(bytesRead, fileSize);
diff --git a/main.js b/main.js
index 2d33409..215d2e1 100644
--- a/main.js
+++ b/main.js
@@ -282,7 +282,8 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' };
- uploadManager = new UploadManager();
+ // Pass hoster settings to the upload manager
+ uploadManager = new UploadManager(config.hosterSettings || {});
uploadManager.on('progress', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -290,6 +291,12 @@ ipcMain.handle('start-upload', (_event, payload) => {
}
});
+ 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
@@ -307,6 +314,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
+
+ // Shutdown after finish
+ handleShutdownAfterFinish();
});
uploadManager.startBatch(tasks);
@@ -360,3 +370,93 @@ ipcMain.handle('app:abort-update', () => {
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);
+ }
+});
diff --git a/package.json b/package.json
index 48e89c1..0c25f30 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
diff --git a/preload.js b/preload.js
index f533482..824a581 100644
--- a/preload.js
+++ b/preload.js
@@ -5,9 +5,25 @@ contextBridge.exposeInMainWorld('api', {
getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getHistory: () => ipcRenderer.invoke('get-history'),
-
clearHistory: () => ipcRenderer.invoke('clear-history'),
+ // Hoster settings
+ getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'),
+ saveHosterSettings: (settings) => ipcRenderer.invoke('save-hoster-settings', settings),
+
+ // Global settings
+ getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
+ saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
+
+ // Always on top
+ setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
+ getAlwaysOnTop: () => ipcRenderer.invoke('get-always-on-top'),
+
+ // Shutdown after finish
+ setShutdownAfterFinish: (mode) => ipcRenderer.invoke('set-shutdown-after-finish', mode),
+ getShutdownAfterFinish: () => ipcRenderer.invoke('get-shutdown-after-finish'),
+ cancelShutdown: () => ipcRenderer.invoke('cancel-shutdown'),
+
// File selection
selectFiles: () => ipcRenderer.invoke('select-files'),
@@ -38,10 +54,18 @@ contextBridge.exposeInMainWorld('api', {
onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
},
+ onUploadStats: (callback) => {
+ ipcRenderer.on('upload-stats', (_event, data) => callback(data));
+ },
+ onShutdownCountdown: (callback) => {
+ ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
+ },
removeAllListeners: () => {
ipcRenderer.removeAllListeners('upload-progress');
ipcRenderer.removeAllListeners('upload-batch-done');
+ ipcRenderer.removeAllListeners('upload-stats');
ipcRenderer.removeAllListeners('app:update-available');
ipcRenderer.removeAllListeners('app:update-progress');
+ ipcRenderer.removeAllListeners('shutdown-countdown');
}
});
diff --git a/renderer/app.js b/renderer/app.js
index 017a231..a3e746a 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -1,43 +1,31 @@
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'];
+// --- State ---
let selectedFiles = []; // { path, name, size }
-let config = { hosters: {} };
-let progressElements = new Map(); // uploadId -> DOM refs
+let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
+let hosterSettings = {};
let uploading = false;
let healthCheckRunning = false;
-const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
let autoHealthCheckEnabled = true;
-const SORT_DEFAULT_DIRECTION = {
- date: 'desc',
- filename: 'asc',
- host: 'asc',
- link: 'asc'
-};
+const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
-function getDefaultSortDirection(key) {
- return SORT_DEFAULT_DIRECTION[key] || 'asc';
-}
+// 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' };
-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' })
- };
-}
+// 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();
- setHealthCheckStatus('Bereit fuer Check');
- renderHealthCheckResults([]);
setupListeners();
- syncAutoCheckToggle();
setupDragDrop();
loadHistory();
@@ -51,6 +39,18 @@ async function init() {
// Update listeners
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
+
+ // Upload event listeners
+ window.api.onUploadProgress(handleProgress);
+ window.api.onUploadBatchDone(handleBatchDone);
+ window.api.onUploadStats(handleStats);
+ window.api.onShutdownCountdown(handleShutdownCountdown);
+
+ // Restore always-on-top state
+ try {
+ const onTop = await window.api.getAlwaysOnTop();
+ alwaysOnTopState = !!onTop;
+ } catch {}
}
// --- Tab switching ---
@@ -64,7 +64,7 @@ document.querySelectorAll('.tab').forEach(tab => {
});
});
-// --- Hoster chips on upload page ---
+// --- Hoster chips ---
function hosterHasCredentials(name, hoster) {
if (name === 'vidmoly.me') return !!(hoster.username && hoster.password);
return !!hoster.apiKey;
@@ -85,6 +85,8 @@ function renderHosterChips() {
`;
chip.querySelector('input').addEventListener('change', (e) => {
chip.classList.toggle('selected', e.target.checked);
+ if (!uploading && selectedFiles.length > 0) buildQueuePreview();
+ updateStartButton();
});
container.appendChild(chip);
}
@@ -98,32 +100,34 @@ function getSelectedHosters() {
// --- 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('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');
- const files = Array.from(e.dataTransfer.files);
- for (const file of files) {
- if (!selectedFiles.find(f => f.path === file.path)) {
- selectedFiles.push({ path: file.path, name: file.name, size: file.size });
- }
- }
- renderFileList();
+ 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() {
@@ -132,266 +136,316 @@ async function pickFiles() {
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: 0 });
+ selectedFiles.push({ path: p, name, size: null }); // size resolved by upload-manager
}
}
- renderFileList();
+ updateUploadView();
}
-function renderFileList() {
- const container = document.getElementById('fileList');
- const actions = document.getElementById('uploadActions');
+function updateUploadView() {
+ const dropZone = document.getElementById('dropZone');
+ const queueContainer = document.getElementById('queueContainer');
+ const queueActions = document.getElementById('queueActions');
- if (selectedFiles.length === 0) {
- container.innerHTML = '';
- actions.style.display = 'none';
- document.getElementById('dropZone').classList.remove('hidden');
- return;
+ 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();
+ }
}
-
- document.getElementById('dropZone').classList.add('hidden');
- actions.style.display = 'flex';
-
- container.innerHTML = selectedFiles.map((f, i) => {
- const sizeStr = f.size > 0 ? formatSize(f.size) : '';
- return `
- ${escapeHtml(f.name)}
- ${sizeStr}
-
-
`;
- }).join('');
-
- container.querySelectorAll('.remove-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- selectedFiles.splice(parseInt(btn.dataset.index), 1);
- renderFileList();
- });
- });
+ updateStartButton();
}
-function setupListeners() {
- document.getElementById('pickFilesBtn').addEventListener('click', (e) => {
- e.stopPropagation();
- pickFiles();
- });
+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;
+}
- document.getElementById('startUploadBtn').addEventListener('click', startUpload);
- document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload);
- document.getElementById('clearFilesBtn').addEventListener('click', () => {
- selectedFiles = [];
- renderFileList();
- });
- document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks);
- document.getElementById('newUploadBtn').addEventListener('click', resetUploadView);
- document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
- document.getElementById('clearHistoryBtn').addEventListener('click', clearHistory);
- document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck);
- const autoToggle = document.getElementById('autoHealthCheckToggle');
- if (autoToggle) {
- autoToggle.addEventListener('change', (e) => {
- autoHealthCheckEnabled = !!e.target.checked;
- saveAutoCheckPreference(autoHealthCheckEnabled);
- });
- }
+// 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');
- // Upload progress events
- window.api.onUploadProgress(handleProgress);
- window.api.onUploadBatchDone(handleBatchDone);
-
- // Copy buttons (delegated)
- document.addEventListener('click', (e) => {
- if (e.target.classList.contains('copy-btn')) {
- const url = e.target.dataset.url;
- if (url) {
- window.api.copyToClipboard(url);
- e.target.textContent = 'Kopiert!';
- e.target.classList.add('copied');
- setTimeout(() => {
- e.target.textContent = 'Kopieren';
- e.target.classList.remove('copied');
- }, 1500);
+ 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.round((job.progress || 0) * 100);
+ const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
+
+ return `
+ | ${escapeHtml(job.fileName)} |
+ ${uploadedSize} |
+ ${escapeHtml(job.hoster)} |
+ ${statusText} |
+ ${elapsed} |
+ ${remaining} |
+ ${speed} |
+
+
+
+ ${job.status === 'preview' ? '' : pct + '%'}
+
+ |
+
`;
+ }).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 loadAutoCheckPreference() {
- try {
- const raw = window.localStorage.getItem(AUTO_CHECK_PREF_KEY);
- if (raw === null) return true;
- return raw === '1';
- } catch {
- return true;
+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;
}
}
-function saveAutoCheckPreference(enabled) {
- try {
- window.localStorage.setItem(AUTO_CHECK_PREF_KEY, enabled ? '1' : '0');
- } catch {}
-}
+// --- Queue interactions ---
+function handleRowClick(e, row) {
+ const jobId = row.dataset.jobId;
-function syncAutoCheckToggle() {
- const autoToggle = document.getElementById('autoHealthCheckToggle');
- if (!autoToggle) return;
- autoToggle.checked = !!autoHealthCheckEnabled;
-}
-
-function setHealthCheckButtonBusy(isBusy, label) {
- const btn = document.getElementById('runHealthCheckBtn');
- if (!btn) return;
- btn.disabled = !!isBusy;
- btn.textContent = isBusy ? (label || 'Pruefe...') : 'Hoster Check';
-}
-
-function getHealthCheckHosters() {
- const selected = getSelectedHosters().filter(name => name === 'doodstream.com' || name === 'vidmoly.me');
- if (selected.length > 0) return selected;
-
- return ['doodstream.com', 'vidmoly.me']
- .filter((name) => hosterHasCredentials(name, config.hosters[name] || {}));
-}
-
-function normalizeHealthStatus(status) {
- if (status === 'ok' || status === 'warn' || status === 'error' || status === 'skipped') {
- return status;
+ 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');
+ }
+ }
}
- return 'skipped';
+ renderQueueTable();
}
-function healthStatusLabel(status) {
- if (status === 'ok') return 'OK';
- if (status === 'warn') return 'WARN';
- if (status === 'error') return 'ERR';
- return 'SKIP';
-}
+// --- Context menu ---
+let alwaysOnTopState = false;
-function setHealthCheckStatus(text) {
- const statusEl = document.getElementById('healthCheckStatus');
- if (!statusEl) return;
- statusEl.textContent = text || '';
-}
-
-function renderHealthCheckResults(results) {
- const container = document.getElementById('healthCheckResults');
- if (!container) return;
-
- if (!results || results.length === 0) {
- container.innerHTML = '';
- return;
+function handleRowContextMenu(e, row) {
+ e.preventDefault();
+ const jobId = row.dataset.jobId;
+ if (!selectedJobIds.has(jobId)) {
+ selectedJobIds.clear();
+ selectedJobIds.add(jobId);
+ renderQueueTable();
}
-
- container.innerHTML = results.map((item) => {
- const status = normalizeHealthStatus(item.status);
- const hoster = escapeHtml(item.hoster || 'unbekannt');
- const message = escapeHtml(item.message || '');
- const tag = healthStatusLabel(status);
- return `
- ${hoster}
- [${tag}]
- ${message}
-
`;
- }).join('');
+ showContextMenu(e.clientX, e.clientY);
}
-async function executeHealthCheck(hosters, mode) {
- const label = mode === 'auto' ? 'Auto-Check' : 'Check';
- setHealthCheckStatus(`Pruefe ${hosters.join(', ')} ...`);
- renderHealthCheckResults([]);
+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';
- const result = await window.api.runHealthCheck({ hosters });
- const rows = result && Array.isArray(result.results) ? result.results : [];
- renderHealthCheckResults(rows);
-
- const okCount = rows.filter((r) => r.status === 'ok').length;
- const warnCount = rows.filter((r) => r.status === 'warn').length;
- const errCount = rows.filter((r) => r.status === 'error').length;
- setHealthCheckStatus(`${label} fertig: ${okCount} OK, ${warnCount} Warnung, ${errCount} Fehler`);
-
- return rows;
+ menu.style.display = 'block';
+ menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px';
+ menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
}
-async function runHealthCheck() {
- if (healthCheckRunning || uploading) return;
+function hideContextMenu() {
+ document.getElementById('contextMenu').style.display = 'none';
+}
- const hosters = getHealthCheckHosters();
- if (hosters.length === 0) {
- alert('Bitte doodstream.com und/oder vidmoly.me mit Zugangsdaten aktivieren.');
- return;
+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);
}
+}
- healthCheckRunning = true;
- setHealthCheckButtonBusy(true, 'Pruefe...');
-
- try {
- await executeHealthCheck(hosters, 'manual');
- } catch (err) {
- setHealthCheckStatus('Health-Check fehlgeschlagen');
- renderHealthCheckResults([{ hoster: 'system', status: 'error', message: err.message || 'Unbekannter Fehler' }]);
- } finally {
- healthCheckRunning = false;
- setHealthCheckButtonBusy(false);
- }
+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) {
- alert('Bitte warten, bis der laufende Hoster-Check fertig ist.');
- return;
- }
+ if (healthCheckRunning || uploading) return;
const hosters = getSelectedHosters();
- if (hosters.length === 0) {
- alert('Bitte mindestens einen Hoster auswaehlen.');
- return;
- }
- if (selectedFiles.length === 0) return;
+ 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');
+ const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me');
if (checkHosters.length > 0) {
healthCheckRunning = true;
- setHealthCheckButtonBusy(true, 'Auto-Check...');
-
try {
const rows = await executeHealthCheck(checkHosters, 'auto');
- const errors = rows.filter((r) => r.status === 'error');
+ const errors = rows.filter(r => r.status === 'error');
if (errors.length > 0) {
- const details = errors
- .map((r) => `${r.hoster || 'hoster'}: ${r.message || 'Fehler'}`)
- .join('\n');
- alert(`Auto-Check fehlgeschlagen:\n${details}\n\nUpload wurde nicht gestartet.`);
+ alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`);
return;
}
} catch (err) {
- const msg = err && err.message ? err.message : 'Unbekannter Fehler';
- setHealthCheckStatus('Auto-Check fehlgeschlagen');
- renderHealthCheckResults([{ hoster: 'system', status: 'error', message: msg }]);
- alert(`Auto-Check fehlgeschlagen: ${msg}\nUpload wurde nicht gestartet.`);
+ alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`);
return;
} finally {
healthCheckRunning = false;
- setHealthCheckButtonBusy(false);
}
}
}
uploading = true;
- document.getElementById('uploadActions').style.display = 'none';
- document.getElementById('cancelActions').style.display = 'flex';
- document.getElementById('resultsSection').style.display = 'block';
- const resultsTitle = document.getElementById('resultsTitle');
- if (resultsTitle) resultsTitle.textContent = 'Ergebnisse (live)';
+ // Convert preview jobs to queued
+ queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; });
+ renderQueueTable();
- resetLiveResultsState();
- renderResultsTable();
-
- const newUploadBtn = document.getElementById('newUploadBtn');
- if (newUploadBtn) newUploadBtn.disabled = true;
-
- buildProgressUI(selectedFiles, hosters);
- document.getElementById('progressSection').style.display = 'flex';
+ document.getElementById('startUploadBtn').style.display = 'none';
+ document.getElementById('cancelUploadBtn').style.display = 'inline-block';
const result = await window.api.startUpload({
files: selectedFiles.map(f => f.path),
@@ -400,456 +454,273 @@ async function startUpload() {
if (result && result.error) {
alert(result.error);
- resetUploadView();
+ 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('cancelActions').style.display = 'none';
-}
-
-function resetUploadView() {
- uploading = false;
- selectedFiles = [];
- progressElements.clear();
- resetLiveResultsState();
- document.getElementById('fileList').innerHTML = '';
- document.getElementById('progressSection').style.display = 'none';
- document.getElementById('progressSection').innerHTML = '';
- document.getElementById('resultsSection').style.display = 'none';
- document.getElementById('cancelActions').style.display = 'none';
- document.getElementById('uploadActions').style.display = 'none';
- document.getElementById('dropZone').classList.remove('hidden');
- const newUploadBtn = document.getElementById('newUploadBtn');
- if (newUploadBtn) newUploadBtn.disabled = false;
- const resultsTitle = document.getElementById('resultsTitle');
- if (resultsTitle) resultsTitle.textContent = 'Ergebnisse';
-}
-
-// --- Progress UI ---
-function buildProgressUI(files, hosters) {
- const section = document.getElementById('progressSection');
- section.innerHTML = '';
- progressElements.clear();
-
- for (const file of files) {
- const card = document.createElement('div');
- card.className = 'progress-card';
-
- let html = `${escapeHtml(file.name)}
`;
- for (const hoster of hosters) {
- const uid = `${file.path}__${hoster}`;
- html += `
-
-
${hoster}
-
-
0%
-
Warte...
-
`;
- }
- card.innerHTML = html;
- section.appendChild(card);
- }
+ document.getElementById('startUploadBtn').style.display = 'inline-block';
+ document.getElementById('cancelUploadBtn').style.display = 'none';
+ updateStartButton();
}
+// --- Progress handling ---
function handleProgress(data) {
- // Find matching progress row
- const rows = document.querySelectorAll('.progress-row');
- for (const row of rows) {
- const hoster = row.querySelector('.progress-hoster').textContent;
- const fileName = row.closest('.progress-card').querySelector('.file-title').textContent;
- if (hoster === data.hoster && fileName === data.fileName) {
- const fill = row.querySelector('.progress-fill');
- const pct = row.querySelector('.progress-percent');
- const stat = row.querySelector('.progress-status');
-
- if (data.status === 'getting-server') {
- stat.textContent = 'Server...';
- stat.className = 'progress-status';
- } else if (data.status === 'uploading') {
- const percent = Math.round(data.progress * 100);
- fill.style.width = `${percent}%`;
- pct.textContent = `${percent}%`;
- stat.textContent = 'Uploading...';
- stat.className = 'progress-status';
- } else if (data.status === 'done') {
- fill.style.width = '100%';
- fill.classList.add('done');
- pct.textContent = '100%';
- stat.textContent = 'Fertig';
- stat.className = 'progress-status done';
- } else if (data.status === 'error') {
- fill.classList.add('error');
- fill.style.width = '100%';
- pct.textContent = '';
- stat.textContent = data.error || 'Fehler';
- stat.className = 'progress-status error';
- stat.title = data.error || 'Fehler';
- }
- break;
- }
+ // 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);
}
- if (data && (data.status === 'done' || data.status === 'error')) {
- upsertLiveResultRow(data);
- }
+ // 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) {
uploading = false;
- document.getElementById('cancelActions').style.display = 'none';
- mergeSummaryIntoResults(summary);
- renderResultsTable();
- const resultsTitle = document.getElementById('resultsTitle');
- if (resultsTitle) resultsTitle.textContent = 'Ergebnisse';
- const newUploadBtn = document.getElementById('newUploadBtn');
- if (newUploadBtn) newUploadBtn.disabled = false;
- document.getElementById('resultsSection').style.display = 'block';
+ 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';
}
-// --- Results UI (table like z-o-o-m) ---
-let selectedRows = new Set();
-let resultsRowsData = [];
-let resultsSortState = { key: 'date', direction: getDefaultSortDirection('date') };
-let resultsOrderCounter = 0;
-let resultRowIndexByUploadId = new Map();
-let historyRowsData = [];
-let historySortState = { key: 'date', direction: getDefaultSortDirection('date') };
-
-function resetLiveResultsState() {
- selectedRows.clear();
- resultsRowsData = [];
- resultsSortState = { key: 'date', direction: getDefaultSortDirection('date') };
- resultsOrderCounter = 0;
- resultRowIndexByUploadId = new Map();
+function handleStats(data) {
+ 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);
}
-function createResultRow({ dateTs, dateText, filename, host, link, isError, uploadId }) {
- return {
- date: dateText,
- dateTs,
- filename: filename || '',
- host: host || '',
- link: link || '',
- isError: !!isError,
- order: resultsOrderCounter++,
- uploadId: uploadId || null
- };
-}
-
-function upsertLiveResultRow(data) {
- const { ts, text } = formatDateTime(new Date());
- const result = data && data.result && typeof data.result === 'object' ? data.result : {};
-
- const rowData = createResultRow({
- dateTs: ts,
- dateText: text,
- filename: data.fileName || '',
- host: data.hoster || '',
- link: data.status === 'error'
- ? `[Fehler] ${data.error || 'Fehler'}`
- : (result.download_url || result.embed_url || ''),
- isError: data.status === 'error',
- uploadId: data.uploadId
- });
-
- const existingIndex = resultRowIndexByUploadId.get(data.uploadId);
- if (typeof existingIndex === 'number' && resultsRowsData[existingIndex]) {
- const existingOrder = resultsRowsData[existingIndex].order;
- resultsRowsData[existingIndex] = { ...rowData, order: existingOrder };
- } else {
- const insertedIndex = resultsRowsData.push(rowData) - 1;
- if (data.uploadId) resultRowIndexByUploadId.set(data.uploadId, insertedIndex);
- }
-
- renderResultsTable();
-}
-
-function mergeSummaryIntoResults(summary) {
- if (!summary || !Array.isArray(summary.files)) return;
-
- const { ts, text } = formatDateTime(summary.timestamp || new Date());
-
- for (const file of summary.files) {
- for (const r of (file.results || [])) {
- const link = r.status === 'error'
- ? `[Fehler] ${r.error || 'Fehler'}`
- : (r.download_url || r.embed_url || '');
- const isError = r.status === 'error';
-
- const existingIndex = resultsRowsData.findIndex((row) =>
- row.filename === (file.name || '') &&
- row.host === (r.hoster || '') &&
- row.link === link &&
- row.isError === isError
- );
-
- if (existingIndex === -1) {
- resultsRowsData.push(createResultRow({
- dateTs: ts,
- dateText: text,
- filename: file.name || '',
- host: r.hoster || '',
- link,
- isError
- }));
+// --- 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 });
}
}
- }
-}
-
-function getResultsSortIndicator(columnKey) {
- if (resultsSortState.key !== columnKey) return '↕';
- return resultsSortState.direction === 'asc' ? '▲' : '▼';
-}
-
-function sortResultsRows(rows) {
- const sortKey = resultsSortState.key;
- const factor = resultsSortState.direction === 'asc' ? 1 : -1;
-
- return rows.slice().sort((a, b) => {
- let cmp = 0;
-
- if (sortKey === 'date') {
- cmp = a.dateTs - b.dateTs;
- } else {
- const aVal = String(a[sortKey] || '');
- const bVal = String(b[sortKey] || '');
- cmp = aVal.localeCompare(bVal, 'de', { sensitivity: 'base', numeric: true });
- }
-
- if (cmp !== 0) return cmp * factor;
- return a.order - b.order;
});
+ selectedJobIds.clear();
+ renderQueueTable();
+ updateStartButton();
}
-function renderResultsTable() {
- const container = document.getElementById('resultsContainer');
+// --- 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; }
- if (!resultsRowsData.length) {
- container.innerHTML = 'Warte auf erste Upload-Ergebnisse...
';
- return;
- }
-
- const sortedRows = sortResultsRows(resultsRowsData);
-
- const headerCell = (key, label) => {
- const active = resultsSortState.key === key;
- const indicator = getResultsSortIndicator(key);
- return `${label}${indicator} | `;
- };
-
- let html = `
-
-
- ${headerCell('date', 'Date')}
- ${headerCell('filename', 'Filename')}
- ${headerCell('host', 'Host')}
- ${headerCell('link', 'Link')}
-
-
- `;
-
- sortedRows.forEach((row, index) => {
- html += `
- | ${escapeHtml(row.date)} |
- ${escapeHtml(row.filename)} |
- ${escapeHtml(row.host)} |
- ${escapeHtml(row.link)} |
-
`;
- });
-
- html += '
';
- container.innerHTML = html;
-
- container.querySelectorAll('th.sortable').forEach((th) => {
- th.addEventListener('click', () => {
- const key = th.dataset.sortKey;
- if (!key) return;
-
- if (resultsSortState.key === key) {
- resultsSortState.direction = resultsSortState.direction === 'asc' ? 'desc' : 'asc';
- } else {
- resultsSortState.key = key;
- resultsSortState.direction = getDefaultSortDirection(key);
- }
-
- selectedRows.clear();
- renderResultsTable();
- });
- });
-
- // Click handler: select row + copy link
- container.querySelectorAll('.result-row').forEach(tr => {
- tr.addEventListener('click', (e) => {
- const idx = tr.dataset.index;
- const link = tr.dataset.link;
- const isError = tr.classList.contains('error');
-
- if (e.ctrlKey || e.metaKey) {
- // Ctrl+Click: toggle selection
- if (selectedRows.has(idx)) {
- selectedRows.delete(idx);
- tr.classList.remove('selected');
- } else {
- selectedRows.add(idx);
- tr.classList.add('selected');
- }
- // Copy all selected links
- const links = [];
- container.querySelectorAll('.result-row.selected').forEach(r => {
- if (!r.classList.contains('error')) links.push(r.dataset.link);
- });
- if (links.length > 0) {
- window.api.copyToClipboard(links.join('\n'));
- showCopyToast(`${links.length} Links kopiert`);
- }
- } else if (e.shiftKey && selectedRows.size > 0) {
- // Shift+Click: range select
- const allRows = Array.from(container.querySelectorAll('.result-row'));
- const lastSelected = Math.max(...Array.from(selectedRows).map(Number));
- const current = parseInt(idx);
- const from = Math.min(lastSelected, current);
- const to = Math.max(lastSelected, current);
- for (let i = from; i <= to; i++) {
- selectedRows.add(String(i));
- allRows[i].classList.add('selected');
- }
- const links = [];
- container.querySelectorAll('.result-row.selected').forEach(r => {
- if (!r.classList.contains('error')) links.push(r.dataset.link);
- });
- if (links.length > 0) {
- window.api.copyToClipboard(links.join('\n'));
- showCopyToast(`${links.length} Links kopiert`);
- }
- } else {
- // Normal click: select only this row, copy its link
- container.querySelectorAll('.result-row').forEach(r => r.classList.remove('selected'));
- selectedRows.clear();
- selectedRows.add(idx);
- tr.classList.add('selected');
- if (!isError && link) {
- window.api.copyToClipboard(link);
- showCopyToast('Link kopiert');
- }
- }
- });
- });
+ container.innerHTML = results.map(item => {
+ const status = item.status || 'skipped';
+ return `
+ ${escapeHtml(item.hoster || '')}
+ [${status.toUpperCase()}]
+ ${escapeHtml(item.message || '')}
+
`;
+ }).join('');
}
-function buildResultsUI(summary) {
- resetLiveResultsState();
- mergeSummaryIntoResults(summary);
- renderResultsTable();
+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;
}
-function showCopyToast(msg) {
- let toast = document.getElementById('copyToast');
- if (!toast) {
- toast = document.createElement('div');
- toast.id = 'copyToast';
- toast.className = 'copy-toast';
- document.body.appendChild(toast);
- }
- toast.textContent = msg;
- toast.classList.add('show');
- clearTimeout(toast._timer);
- toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
-}
-
-function copyAllLinks() {
- const links = [];
- document.querySelectorAll('#resultsContainer .result-row:not(.error)').forEach(r => {
- links.push(r.dataset.link);
- });
- if (links.length > 0) {
- window.api.copyToClipboard(links.join('\n'));
- const btn = document.getElementById('copyAllLinksBtn');
- btn.textContent = 'Kopiert!';
- setTimeout(() => { btn.textContent = 'Alle Links kopieren'; }, 1500);
+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 grid = document.getElementById('settingsGrid');
- grid.innerHTML = '';
+ 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') {
- // Vidmoly uses username/password
- const block = document.createElement('div');
- block.className = 'settings-block';
- block.innerHTML = `
+ credsHtml = `
- ${name}
+
-
+
-
-
- `;
- block.querySelector('.toggle-vis').addEventListener('click', () => {
- const pwInput = block.querySelector('[data-field="password"]');
- pwInput.type = pwInput.type === 'password' ? 'text' : 'password';
- });
- grid.appendChild(block);
+
+ `;
} else {
- // API key hosters
- const row = document.createElement('div');
- row.className = 'settings-row';
- row.innerHTML = `
- ${name}
-
-
- `;
- row.querySelector('.toggle-vis').addEventListener('click', () => {
- const input = row.querySelector('.key-input');
+ credsHtml = `
+
+
+
+
+
`;
+ }
+
+ panel.innerHTML = `
+
+
+ ${credsHtml}
+
+
Upload Einstellungen
+
+
+ `;
+
+ 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';
});
- grid.appendChild(row);
- }
+ });
}
}
async function saveSettings() {
const hosters = {};
+ const newHosterSettings = {};
for (const name of HOSTERS) {
+ // Credentials
if (name === 'vidmoly.me') {
- const usernameInput = document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`);
- const passwordInput = document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`);
- const username = usernameInput ? usernameInput.value.trim() : '';
- const password = passwordInput ? passwordInput.value.trim() : '';
- hosters[name] = {
- enabled: !!(username && password),
- authType: 'login',
- username,
- password
- };
+ 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 input = document.querySelector(`.key-input[data-hoster="${name}"]`);
- const apiKey = input ? input.value.trim() : '';
- hosters[name] = {
- enabled: !!apiKey,
- apiKey
- };
+ 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();
renderHealthCheckResults([]);
- setHealthCheckStatus('Bereit fuer Check');
const feedback = document.getElementById('saveFeedback');
feedback.textContent = 'Gespeichert!';
@@ -867,25 +738,19 @@ async function loadHistory() {
return;
}
- historySortState = { key: 'date', direction: getDefaultSortDirection('date') };
+ historySortState = { key: 'date', direction: 'desc' };
historyRowsData = [];
-
let order = 0;
- for (const batch of history) {
- const formattedDate = formatDateTime(batch && batch.timestamp ? batch.timestamp : new Date());
+ 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: formattedDate.text,
- dateTs: formattedDate.ts,
- filename: file.name || '',
- host: result.hoster || '',
- link: result.status === 'error'
- ? `[Fehler] ${result.error || 'Fehler'}`
- : (result.download_url || result.embed_url || ''),
- isError: result.status === 'error',
- order: order++
+ 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++
});
}
}
@@ -894,59 +759,24 @@ async function loadHistory() {
renderHistoryTable(container);
}
-function getHistorySortIndicator(columnKey) {
- if (historySortState.key !== columnKey) return '↕';
- return historySortState.direction === 'asc' ? '▲' : '▼';
-}
-
-function sortHistoryRows(rows) {
- const sortKey = historySortState.key;
- const factor = historySortState.direction === 'asc' ? 1 : -1;
-
- return rows.slice().sort((a, b) => {
- let cmp = 0;
-
- if (sortKey === 'date') {
- cmp = a.dateTs - b.dateTs;
- } else {
- const aVal = String(a[sortKey] || '');
- const bVal = String(b[sortKey] || '');
- cmp = aVal.localeCompare(bVal, 'de', { sensitivity: 'base', numeric: true });
- }
-
- if (cmp !== 0) return cmp * factor;
- return a.order - b.order;
- });
-}
-
function renderHistoryTable(container) {
- if (!container) return;
-
- if (!historyRowsData.length) {
- container.innerHTML = 'Noch keine Uploads.
';
+ if (!container || !historyRowsData.length) {
+ if (container) container.innerHTML = 'Noch keine Uploads.
';
return;
}
const rows = sortHistoryRows(historyRowsData);
-
const headerCell = (key, label) => {
const active = historySortState.key === key;
- const indicator = getHistorySortIndicator(key);
- return `${label}${indicator} | `;
+ const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
+ return `${label}${dir} | `;
};
- let html = `
-
-
- ${headerCell('date', 'Date')}
- ${headerCell('filename', 'Filename')}
- ${headerCell('host', 'Host')}
- ${headerCell('link', 'Link')}
-
-
- `;
+ let html = `
+ ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
+
`;
- rows.forEach((row) => {
+ rows.forEach(row => {
html += `
| ${escapeHtml(row.date)} |
${escapeHtml(row.filename)} |
@@ -958,59 +788,87 @@ function renderHistoryTable(container) {
html += '
';
container.innerHTML = html;
- container.querySelectorAll('th.sortable').forEach((th) => {
+ container.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
- const key = th.dataset.historySortKey;
- if (!key) return;
-
- if (historySortState.key === key) {
- historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
- } else {
- historySortState.key = key;
- historySortState.direction = getDefaultSortDirection(key);
- }
-
+ 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) => {
+ container.querySelectorAll('.history-row').forEach(row => {
row.addEventListener('click', () => {
if (row.classList.contains('error')) return;
-
const link = row.dataset.link;
- if (!link) return;
-
- container.querySelectorAll('.history-row').forEach((r) => r.classList.remove('selected'));
- row.classList.add('selected');
- window.api.copyToClipboard(link);
- showCopyToast('Link kopiert');
+ if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
});
});
}
-async function clearHistory() {
- if (!confirm('Verlauf wirklich loeschen?')) return;
- await window.api.clearHistory();
- loadHistory();
+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;
+ });
}
-// --- Utilities ---
-function formatSize(bytes) {
- 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';
-}
+// --- 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();
+ });
-function escapeHtml(str) {
- if (!str) return '';
- return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
+ // 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 {}
+ });
+ }
-function escapeAttr(str) {
- if (!str) return '';
- return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
+ // 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 ---
@@ -1020,35 +878,120 @@ function showUpdateBanner(info) {
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';
- };
+ 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') {
+ 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, '"');
+}
+
+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();
diff --git a/renderer/index.html b/renderer/index.html
index ca2c56a..80f06e2 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -3,7 +3,7 @@
- Multi Hoster Uploader
+ Multi-Hoster-Upload
@@ -22,58 +22,68 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
📁
Dateien hierher ziehen oder klicken
-
-
-
-
-
-
+
+
+
+
+
+ | File name |
+ Uploaded / Size |
+ Host |
+ Status |
+ Elapsed |
+ Remain. |
+ Speed |
+ Progress |
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
API Keys
-
API-Keys findest du in den Einstellungen der jeweiligen Hoster-Webseite.
-
-
+
Hoster Konfiguration
+
API-Keys und Upload-Einstellungen pro Hoster.
+
+
@@ -89,6 +99,49 @@
+
+
+
+
+
+ Bereit
+ |
+ 0 kB/s
+ |
+ 0 B
+ |
+ 00:00:00
+
+
+
+
+
+
+
+
+
System wird heruntergefahren in 60s...
+
+
+
+