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>
This commit is contained in:
parent
ab21e7b8ba
commit
25b2afbf11
@ -1,6 +1,15 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 = {
|
const DEFAULTS = {
|
||||||
hosters: {
|
hosters: {
|
||||||
'doodstream.com': { enabled: true, apiKey: '' },
|
'doodstream.com': { enabled: true, apiKey: '' },
|
||||||
@ -8,6 +17,16 @@ const DEFAULTS = {
|
|||||||
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
|
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
|
||||||
'byse.sx': { enabled: true, apiKey: '' }
|
'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: []
|
history: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -32,7 +51,19 @@ class ConfigStore {
|
|||||||
hosters[name] = { ...hosters[name], ...val };
|
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 {
|
} catch {
|
||||||
return JSON.parse(JSON.stringify(DEFAULTS));
|
return JSON.parse(JSON.stringify(DEFAULTS));
|
||||||
}
|
}
|
||||||
@ -40,8 +71,12 @@ class ConfigStore {
|
|||||||
|
|
||||||
save(config) {
|
save(config) {
|
||||||
const current = this.load();
|
const current = this.load();
|
||||||
// Only update hosters, keep history
|
// Update hosters credentials
|
||||||
current.hosters = config.hosters || current.hosters;
|
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');
|
fs.writeFileSync(this.filePath, JSON.stringify(current, null, 2), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -238,7 +238,7 @@ function buildMultipart(filePath, formFields) {
|
|||||||
return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize };
|
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);
|
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
|
||||||
|
|
||||||
let bytesRead = 0;
|
let bytesRead = 0;
|
||||||
@ -248,6 +248,7 @@ function createUploadBody(filePath, formFields, onProgress) {
|
|||||||
yield preambleBuf;
|
yield preambleBuf;
|
||||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
for await (const chunk of fileStream) {
|
for await (const chunk of fileStream) {
|
||||||
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
bytesRead += chunk.length;
|
bytesRead += chunk.length;
|
||||||
yield chunk;
|
yield chunk;
|
||||||
if (onProgress) onProgress(bytesRead, fileSize);
|
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.');
|
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];
|
const config = HOSTER_CONFIGS[hosterName];
|
||||||
if (!config) throw new Error(`Unbekannter Hoster: ${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 targetUrl = config.buildUploadUrl(uploadUrl, apiKey);
|
||||||
const formFields = config.formFields(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, {
|
const { body, statusCode } = await request(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
48
lib/semaphore.js
Normal file
48
lib/semaphore.js
Normal file
@ -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;
|
||||||
42
lib/throttle.js
Normal file
42
lib/throttle.js
Normal file
@ -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;
|
||||||
@ -4,21 +4,53 @@ const fs = require('fs');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { uploadFile } = require('./hosters');
|
const { uploadFile } = require('./hosters');
|
||||||
const VidmolyUploader = require('./vidmoly-upload');
|
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 {
|
class UploadManager extends EventEmitter {
|
||||||
constructor() {
|
constructor(hosterSettings) {
|
||||||
super();
|
super();
|
||||||
|
this.hosterSettings = hosterSettings || {};
|
||||||
|
this.semaphores = {};
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.running = false;
|
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) {
|
async startBatch(tasks) {
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.sessionBytes = 0;
|
||||||
|
this.activeJobs.clear();
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
|
|
||||||
const batchId = `batch-${Date.now()}`;
|
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
|
// Initialize result map per file
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
@ -30,96 +62,14 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build upload promises
|
// Start global stats emitter
|
||||||
const promises = tasks.map(async (task) => {
|
this._startStatsTimer();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Create job promises — semaphore controls concurrency per hoster
|
||||||
|
const promises = tasks.map((task) => this._runJob(task, results, signal));
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
this._stopStatsTimer();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|
||||||
const files = Array.from(results.values());
|
const files = Array.from(results.values());
|
||||||
@ -138,10 +88,301 @@ class UploadManager extends EventEmitter {
|
|||||||
this.emit('batch-done', summary);
|
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() {
|
cancel() {
|
||||||
if (this.running) {
|
if (this.running) {
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
this._stopStatsTimer();
|
||||||
|
this.activeJobs.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -169,7 +169,7 @@ class VidmolyUploader {
|
|||||||
/**
|
/**
|
||||||
* Upload a file to Vidmoly (uses undici.request for streaming progress)
|
* 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 fileName = path.basename(filePath);
|
||||||
const fileSize = fs.statSync(filePath).size;
|
const fileSize = fs.statSync(filePath).size;
|
||||||
const baselineCodes = await this._captureVmFileCodes();
|
const baselineCodes = await this._captureVmFileCodes();
|
||||||
@ -210,6 +210,7 @@ class VidmolyUploader {
|
|||||||
yield preambleBuf;
|
yield preambleBuf;
|
||||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
for await (const chunk of fileStream) {
|
for await (const chunk of fileStream) {
|
||||||
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
bytesRead += chunk.length;
|
bytesRead += chunk.length;
|
||||||
yield chunk;
|
yield chunk;
|
||||||
if (onProgress) onProgress(bytesRead, fileSize);
|
if (onProgress) onProgress(bytesRead, fileSize);
|
||||||
|
|||||||
102
main.js
102
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.' };
|
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) => {
|
uploadManager.on('progress', (data) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
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) => {
|
uploadManager.on('batch-done', (summary) => {
|
||||||
configStore.appendHistory(summary);
|
configStore.appendHistory(summary);
|
||||||
// Write successful uploads to fileuploader.log
|
// Write successful uploads to fileuploader.log
|
||||||
@ -307,6 +314,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('upload-batch-done', summary);
|
mainWindow.webContents.send('upload-batch-done', summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown after finish
|
||||||
|
handleShutdownAfterFinish();
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadManager.startBatch(tasks);
|
uploadManager.startBatch(tasks);
|
||||||
@ -360,3 +370,93 @@ ipcMain.handle('app:abort-update', () => {
|
|||||||
ipcMain.handle('app:get-version', () => {
|
ipcMain.handle('app:get-version', () => {
|
||||||
return app.getVersion();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
26
preload.js
26
preload.js
@ -5,9 +5,25 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||||
getHistory: () => ipcRenderer.invoke('get-history'),
|
getHistory: () => ipcRenderer.invoke('get-history'),
|
||||||
|
|
||||||
clearHistory: () => ipcRenderer.invoke('clear-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
|
// File selection
|
||||||
selectFiles: () => ipcRenderer.invoke('select-files'),
|
selectFiles: () => ipcRenderer.invoke('select-files'),
|
||||||
|
|
||||||
@ -38,10 +54,18 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onUploadBatchDone: (callback) => {
|
onUploadBatchDone: (callback) => {
|
||||||
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
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: () => {
|
removeAllListeners: () => {
|
||||||
ipcRenderer.removeAllListeners('upload-progress');
|
ipcRenderer.removeAllListeners('upload-progress');
|
||||||
ipcRenderer.removeAllListeners('upload-batch-done');
|
ipcRenderer.removeAllListeners('upload-batch-done');
|
||||||
|
ipcRenderer.removeAllListeners('upload-stats');
|
||||||
ipcRenderer.removeAllListeners('app:update-available');
|
ipcRenderer.removeAllListeners('app:update-available');
|
||||||
ipcRenderer.removeAllListeners('app:update-progress');
|
ipcRenderer.removeAllListeners('app:update-progress');
|
||||||
|
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
1415
renderer/app.js
1415
renderer/app.js
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
|
||||||
<title>Multi Hoster Uploader</title>
|
<title>Multi-Hoster-Upload</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -22,58 +22,68 @@
|
|||||||
|
|
||||||
<!-- Upload View -->
|
<!-- Upload View -->
|
||||||
<div id="upload-view" class="view active">
|
<div id="upload-view" class="view active">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="upload-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
<div class="hoster-select" id="hosterSelect"></div>
|
<div class="hoster-select" id="hosterSelect"></div>
|
||||||
|
</div>
|
||||||
<div class="health-check-panel">
|
<div class="toolbar-right">
|
||||||
<div class="health-check-actions">
|
<div class="health-check-inline">
|
||||||
<button class="btn btn-secondary" id="runHealthCheckBtn">Hoster Check</button>
|
<button class="btn btn-xs btn-secondary" id="runHealthCheckBtn" title="Hoster Check">Check</button>
|
||||||
<span class="health-check-status" id="healthCheckStatus"></span>
|
<label class="auto-check-label" title="Auto-Check vor Upload">
|
||||||
<label class="auto-health-check" title="Fuehrt vor dem Upload automatisch einen Hoster-Check aus">
|
|
||||||
<input type="checkbox" id="autoHealthCheckToggle" checked>
|
<input type="checkbox" id="autoHealthCheckToggle" checked>
|
||||||
<span>Auto-Check vor Upload</span>
|
<span>Auto</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="health-check-results" id="healthCheckResults"></div>
|
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
||||||
|
<button class="btn btn-xs btn-success" id="startUploadBtn" disabled>Upload starten</button>
|
||||||
|
<button class="btn btn-xs btn-danger" id="cancelUploadBtn" style="display:none">Abbrechen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Health check results (collapsible) -->
|
||||||
|
<div class="health-check-results" id="healthCheckResults"></div>
|
||||||
|
|
||||||
|
<!-- Drop zone (shown when no files) -->
|
||||||
<div class="drop-zone" id="dropZone">
|
<div class="drop-zone" id="dropZone">
|
||||||
<div class="drop-icon">📁</div>
|
<div class="drop-icon">📁</div>
|
||||||
<p>Dateien hierher ziehen oder klicken</p>
|
<p>Dateien hierher ziehen oder klicken</p>
|
||||||
<button class="btn btn-primary" id="pickFilesBtn">Dateien waehlen</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-list" id="fileList"></div>
|
<!-- Queue Table -->
|
||||||
|
<div class="queue-container" id="queueContainer" style="display:none">
|
||||||
<div class="upload-actions" id="uploadActions" style="display:none">
|
<table class="queue-table" id="queueTable">
|
||||||
<button class="btn btn-primary" id="startUploadBtn">Upload starten</button>
|
<thead>
|
||||||
<button class="btn btn-secondary" id="clearFilesBtn">Liste leeren</button>
|
<tr>
|
||||||
|
<th class="col-filename sortable" data-sort="filename">File name</th>
|
||||||
|
<th class="col-size sortable" data-sort="size">Uploaded / Size</th>
|
||||||
|
<th class="col-host sortable" data-sort="host">Host</th>
|
||||||
|
<th class="col-status sortable" data-sort="status">Status</th>
|
||||||
|
<th class="col-elapsed">Elapsed</th>
|
||||||
|
<th class="col-remaining">Remain.</th>
|
||||||
|
<th class="col-speed sortable" data-sort="speed">Speed</th>
|
||||||
|
<th class="col-progress">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="queueBody"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-actions" id="cancelActions" style="display:none">
|
<!-- Action bar below queue -->
|
||||||
<button class="btn btn-danger" id="cancelUploadBtn">Abbrechen</button>
|
<div class="queue-actions" id="queueActions" style="display:none">
|
||||||
</div>
|
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
<div class="progress-section" id="progressSection" style="display:none"></div>
|
<button class="btn btn-xs btn-secondary" id="clearQueueBtn">Queue leeren</button>
|
||||||
|
|
||||||
<div class="results-section" id="resultsSection" style="display:none">
|
|
||||||
<div class="results-header">
|
|
||||||
<h2 id="resultsTitle">Ergebnisse</h2>
|
|
||||||
<div class="results-buttons">
|
|
||||||
<button class="btn btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
|
||||||
<button class="btn btn-secondary" id="newUploadBtn">Neuer Upload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="resultsContainer"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings View -->
|
<!-- Settings View -->
|
||||||
<div id="settings-view" class="view">
|
<div id="settings-view" class="view">
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<h2>API Keys</h2>
|
<h2>Hoster Konfiguration</h2>
|
||||||
<p class="settings-hint">API-Keys findest du in den Einstellungen der jeweiligen Hoster-Webseite.</p>
|
<p class="settings-hint">API-Keys und Upload-Einstellungen pro Hoster.</p>
|
||||||
<div class="settings-grid" id="settingsGrid"></div>
|
<div class="settings-hosters" id="settingsHosters"></div>
|
||||||
<button class="btn btn-primary" id="saveSettingsBtn">Speichern</button>
|
<button class="btn btn-primary" id="saveSettingsBtn">Alles Speichern</button>
|
||||||
<span class="save-feedback" id="saveFeedback"></span>
|
<span class="save-feedback" id="saveFeedback"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,6 +99,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Context Menu -->
|
||||||
|
<div class="context-menu" id="contextMenu" style="display:none">
|
||||||
|
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
|
||||||
|
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
||||||
|
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
|
||||||
|
<div class="ctx-separator"></div>
|
||||||
|
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
|
||||||
|
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
|
||||||
|
<div class="ctx-separator"></div>
|
||||||
|
<div class="ctx-item ctx-submenu" data-action="shutdown">
|
||||||
|
Shutdown nach Finish
|
||||||
|
<div class="ctx-submenu-items">
|
||||||
|
<div class="ctx-item" data-action="shutdown-nothing">Nichts</div>
|
||||||
|
<div class="ctx-item" data-action="shutdown-sleep">Ruhezustand</div>
|
||||||
|
<div class="ctx-item" data-action="shutdown-shutdown">Herunterfahren</div>
|
||||||
|
<div class="ctx-item" data-action="shutdown-restart">Neustart</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-item" data-action="always-on-top">Immer im Vordergrund</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statusbar -->
|
||||||
|
<div class="statusbar" id="statusbar">
|
||||||
|
<span class="sb-state" id="sbState">Bereit</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-speed" id="sbSpeed">0 kB/s</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-total" id="sbTotal">0 B</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-elapsed" id="sbElapsed">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy toast -->
|
||||||
|
<div class="copy-toast" id="copyToast"></div>
|
||||||
|
|
||||||
|
<!-- Shutdown countdown -->
|
||||||
|
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
|
||||||
|
<div class="shutdown-box">
|
||||||
|
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
||||||
|
<button class="btn btn-danger" id="cancelShutdownBtn">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1184
renderer/styles.css
1184
renderer/styles.css
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user