feat: improve account-driven uploads
This commit is contained in:
parent
cc5ee47fb8
commit
f59539e85b
@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const HOSTER_SETTINGS_DEFAULTS = {
|
const HOSTER_SETTINGS_DEFAULTS = {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
maxSpeedKbs: 0, // 0 = unlimited
|
maxSpeedKbs: 0, // 0 = unlimited
|
||||||
parallelCount: 2, // 1-10
|
parallelCount: 2, // 1-100
|
||||||
restartBelowKbs: 0, // 0 = off
|
restartBelowKbs: 0, // 0 = off
|
||||||
timeIntervalSec: 0, // delay between jobs
|
timeIntervalSec: 0, // delay between jobs
|
||||||
maxSizeMb: 0 // 0 = unlimited
|
maxSizeMb: 0 // 0 = unlimited
|
||||||
@ -28,6 +28,8 @@ const DEFAULTS = {
|
|||||||
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
||||||
logFilePath: '',
|
logFilePath: '',
|
||||||
resumeQueueOnLaunch: true,
|
resumeQueueOnLaunch: true,
|
||||||
|
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
||||||
|
scaleParallelUploads: false,
|
||||||
removeFromQueueOnDone: false,
|
removeFromQueueOnDone: false,
|
||||||
pendingQueue: null,
|
pendingQueue: null,
|
||||||
scramble: {
|
scramble: {
|
||||||
|
|||||||
@ -19,42 +19,75 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class UploadManager extends EventEmitter {
|
class UploadManager extends EventEmitter {
|
||||||
constructor(hosterSettings) {
|
constructor(hosterSettings, globalSettings) {
|
||||||
super();
|
super();
|
||||||
this.hosterSettings = hosterSettings || {};
|
this.hosterSettings = hosterSettings || {};
|
||||||
|
this.globalSettings = globalSettings || {};
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
|
this.globalSemaphore = null;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
this.stopAfterActive = false;
|
||||||
this.statsInterval = null;
|
this.statsInterval = null;
|
||||||
this.startTime = 0;
|
this.startTime = 0;
|
||||||
this.activeJobs = new Map(); // uploadId -> { speedKbs, bytesUploaded }
|
this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded }
|
||||||
|
this.jobAbortControllers = new Map(); // jobId -> AbortController
|
||||||
|
this.cancelledJobIds = new Set();
|
||||||
this.sessionBytes = 0;
|
this.sessionBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSettings(hoster) {
|
_getSettings(hoster) {
|
||||||
return { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
|
const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
|
||||||
|
const globalLimit = this._getGlobalParallelLimit();
|
||||||
|
if (this.globalSettings.scaleParallelUploads && globalLimit > 0) {
|
||||||
|
settings.parallelCount = Math.max(settings.parallelCount || 1, globalLimit);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getGlobalParallelLimit() {
|
||||||
|
const raw = Number(this.globalSettings.parallelUploadCount || 0);
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return 0;
|
||||||
|
return Math.max(1, Math.min(100, Math.round(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_getGlobalSemaphore() {
|
||||||
|
const limit = this._getGlobalParallelLimit();
|
||||||
|
if (limit <= 0) return null;
|
||||||
|
if (!this.globalSemaphore) {
|
||||||
|
this.globalSemaphore = new Semaphore(limit);
|
||||||
|
} else {
|
||||||
|
this.globalSemaphore.updateLimit(limit);
|
||||||
|
}
|
||||||
|
return this.globalSemaphore;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSemaphore(hoster) {
|
_getSemaphore(hoster) {
|
||||||
if (!this.semaphores[hoster]) {
|
if (!this.semaphores[hoster]) {
|
||||||
const settings = this._getSettings(hoster);
|
const settings = this._getSettings(hoster);
|
||||||
this.semaphores[hoster] = new Semaphore(settings.parallelCount);
|
this.semaphores[hoster] = new Semaphore(settings.parallelCount);
|
||||||
|
} else {
|
||||||
|
this.semaphores[hoster].updateLimit(this._getSettings(hoster).parallelCount);
|
||||||
}
|
}
|
||||||
return this.semaphores[hoster];
|
return this.semaphores[hoster];
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBatch(tasks) {
|
async startBatch(tasks) {
|
||||||
this.running = true;
|
this.running = true;
|
||||||
|
this.stopAfterActive = false;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.startTime = Date.now();
|
this.startTime = Date.now();
|
||||||
this.sessionBytes = 0;
|
this.sessionBytes = 0;
|
||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
const { signal } = this.abortController;
|
this.jobAbortControllers.clear();
|
||||||
|
this.cancelledJobIds.clear();
|
||||||
|
this.semaphores = {};
|
||||||
|
this.globalSemaphore = null;
|
||||||
|
|
||||||
|
const { signal } = this.abortController;
|
||||||
const batchId = `batch-${Date.now()}`;
|
const batchId = `batch-${Date.now()}`;
|
||||||
const results = new Map(); // filePath -> { name, size, results: [] }
|
const results = new Map(); // filePath -> { name, size, results: [] }
|
||||||
|
|
||||||
// Initialize result map per file
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const fileName = path.basename(task.file);
|
const fileName = path.basename(task.file);
|
||||||
if (!results.has(task.file)) {
|
if (!results.has(task.file)) {
|
||||||
@ -64,34 +97,17 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start global stats emitter
|
|
||||||
this._startStatsTimer();
|
this._startStatsTimer();
|
||||||
|
|
||||||
// Group tasks by file to process files top-to-bottom
|
const promises = tasks.map((task) => this._runJob(task, results, signal));
|
||||||
// Within each file, all hosters run in parallel
|
|
||||||
const fileOrder = [];
|
|
||||||
const tasksByFile = new Map();
|
|
||||||
for (const task of tasks) {
|
|
||||||
if (!tasksByFile.has(task.file)) {
|
|
||||||
tasksByFile.set(task.file, []);
|
|
||||||
fileOrder.push(task.file);
|
|
||||||
}
|
|
||||||
tasksByFile.get(task.file).push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of fileOrder) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
const fileTasks = tasksByFile.get(file);
|
|
||||||
const promises = fileTasks.map((task) => this._runJob(task, results, signal));
|
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
}
|
|
||||||
|
|
||||||
this._stopStatsTimer();
|
this._stopStatsTimer();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|
||||||
const files = Array.from(results.values());
|
const files = Array.from(results.values());
|
||||||
const total = tasks.length;
|
const total = tasks.length;
|
||||||
const succeeded = files.reduce((n, f) => n + f.results.filter(r => r.status === 'done').length, 0);
|
const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0);
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
id: batchId,
|
id: batchId,
|
||||||
@ -105,77 +121,113 @@ class UploadManager extends EventEmitter {
|
|||||||
this.emit('batch-done', summary);
|
this.emit('batch-done', summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runJob(task, results, signal) {
|
async _runJob(task, results, batchSignal) {
|
||||||
const settings = this._getSettings(task.hoster);
|
const settings = this._getSettings(task.hoster);
|
||||||
const semaphore = this._getSemaphore(task.hoster);
|
const hosterSemaphore = this._getSemaphore(task.hoster);
|
||||||
|
const globalSemaphore = this._getGlobalSemaphore();
|
||||||
const uploadId = crypto.randomBytes(8).toString('hex');
|
const uploadId = crypto.randomBytes(8).toString('hex');
|
||||||
|
const jobId = task.jobId || uploadId;
|
||||||
const fileName = path.basename(task.file);
|
const fileName = path.basename(task.file);
|
||||||
let fileSize = 0;
|
let fileSize = 0;
|
||||||
try { fileSize = fs.statSync(task.file).size; } catch {}
|
try { fileSize = fs.statSync(task.file).size; } catch {}
|
||||||
|
|
||||||
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
||||||
|
const jobAbortController = new AbortController();
|
||||||
|
const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal);
|
||||||
|
this.jobAbortControllers.set(jobId, jobAbortController);
|
||||||
|
|
||||||
// File size filter
|
let hosterSlotAcquired = false;
|
||||||
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
|
let globalSlotAcquired = false;
|
||||||
const errMsg = `Datei zu gross (Max: ${settings.maxSizeMb} MB)`;
|
let finalResultRecorded = false;
|
||||||
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 (abortable)
|
|
||||||
try {
|
|
||||||
await semaphore.acquire(signal);
|
|
||||||
} catch {
|
|
||||||
// Aborted while waiting in queue — no slot was granted, no release needed
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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;
|
let lastError = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
const recordFinalResult = (status, payload = {}) => {
|
||||||
if (signal.aborted) break;
|
if (finalResultRecorded) return;
|
||||||
|
finalResultRecorded = true;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
hoster: task.hoster,
|
||||||
|
status,
|
||||||
|
error: payload.error || null,
|
||||||
|
download_url: payload.result ? payload.result.download_url || null : null,
|
||||||
|
embed_url: payload.result ? payload.result.embed_url || null : null,
|
||||||
|
file_code: payload.result ? payload.result.file_code || null : null
|
||||||
|
};
|
||||||
|
|
||||||
|
results.get(task.file).results.push(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitFinalStatus = (status, payload = {}) => {
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, {
|
||||||
|
jobId,
|
||||||
|
status,
|
||||||
|
progress: status === 'done' ? 1 : 0,
|
||||||
|
bytesUploaded: status === 'done' ? fileSize : 0,
|
||||||
|
bytesTotal: fileSize,
|
||||||
|
speedKbs: payload.speedKbs || 0,
|
||||||
|
elapsed: payload.elapsed || 0,
|
||||||
|
remaining: 0,
|
||||||
|
error: payload.error || null,
|
||||||
|
result: payload.result || null,
|
||||||
|
attempt: payload.attempt || maxAttempts,
|
||||||
|
maxAttempts
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
|
||||||
|
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
|
||||||
|
emitFinalStatus('skipped', { error, attempt: 0 });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, {
|
||||||
|
jobId,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
bytesUploaded: 0,
|
||||||
|
bytesTotal: fileSize,
|
||||||
|
speedKbs: 0,
|
||||||
|
elapsed: 0,
|
||||||
|
remaining: 0,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
attempt: 0,
|
||||||
|
maxAttempts
|
||||||
|
});
|
||||||
|
|
||||||
|
if (globalSemaphore) {
|
||||||
|
await globalSemaphore.acquire(signal);
|
||||||
|
globalSlotAcquired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await hosterSemaphore.acquire(signal);
|
||||||
|
hosterSlotAcquired = true;
|
||||||
|
|
||||||
|
if (settings.timeIntervalSec > 0) {
|
||||||
|
await this._sleep(settings.timeIntervalSec * 1000, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
|
||||||
// Retry delay
|
|
||||||
if (attempt > 1) {
|
if (attempt > 1) {
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, {
|
||||||
status: 'retrying', progress: 0,
|
jobId,
|
||||||
bytesUploaded: 0, bytesTotal: fileSize,
|
status: 'retrying',
|
||||||
speedKbs: 0, elapsed: 0, remaining: 0,
|
progress: 0,
|
||||||
|
bytesUploaded: 0,
|
||||||
|
bytesTotal: fileSize,
|
||||||
|
speedKbs: 0,
|
||||||
|
elapsed: 0,
|
||||||
|
remaining: 0,
|
||||||
error: lastError ? lastError.message : null,
|
error: lastError ? lastError.message : null,
|
||||||
result: null, attempt, maxAttempts
|
result: null,
|
||||||
|
attempt,
|
||||||
|
maxAttempts
|
||||||
});
|
});
|
||||||
await this._sleep(2500, signal).catch(() => {});
|
await this._sleep(2500, signal);
|
||||||
if (signal.aborted) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobStart = Date.now();
|
const jobStart = Date.now();
|
||||||
@ -184,47 +236,37 @@ class UploadManager extends EventEmitter {
|
|||||||
let currentSpeedKbs = 0;
|
let currentSpeedKbs = 0;
|
||||||
let lowSpeedSince = 0;
|
let lowSpeedSince = 0;
|
||||||
let speedAbort = null;
|
let speedAbort = null;
|
||||||
|
|
||||||
// Register active job for global stats
|
|
||||||
this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 });
|
|
||||||
|
|
||||||
// Speed monitor and signal cleanup (declared outside try for cleanup in catch)
|
|
||||||
let speedMonitor = null;
|
let speedMonitor = null;
|
||||||
let signalCleanup = null;
|
let uploadSignalBundle = { signal, cleanup() {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Getting server
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, {
|
||||||
status: 'getting-server', progress: 0,
|
jobId,
|
||||||
bytesUploaded: 0, bytesTotal: fileSize,
|
status: 'getting-server',
|
||||||
speedKbs: 0, elapsed: 0, remaining: 0,
|
progress: 0,
|
||||||
error: null, result: null, attempt, maxAttempts
|
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
|
const throttle = settings.maxSpeedKbs > 0
|
||||||
? new Throttle(settings.maxSpeedKbs * 1024)
|
? new Throttle(settings.maxSpeedKbs * 1024)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Speed monitor: abort if too slow
|
|
||||||
if (settings.restartBelowKbs > 0) {
|
if (settings.restartBelowKbs > 0) {
|
||||||
speedAbort = new AbortController();
|
speedAbort = new AbortController();
|
||||||
}
|
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
|
||||||
|
|
||||||
// Combined signal
|
|
||||||
let jobSignal = signal;
|
|
||||||
if (speedAbort) {
|
|
||||||
const combined = this._combineSignals(signal, speedAbort.signal);
|
|
||||||
jobSignal = combined.signal;
|
|
||||||
signalCleanup = combined.cleanup;
|
|
||||||
}
|
|
||||||
if (settings.restartBelowKbs > 0) {
|
|
||||||
speedMonitor = setInterval(() => {
|
speedMonitor = setInterval(() => {
|
||||||
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
|
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
|
||||||
if (!lowSpeedSince) lowSpeedSince = Date.now();
|
if (!lowSpeedSince) lowSpeedSince = Date.now();
|
||||||
if (Date.now() - lowSpeedSince > 6000) {
|
if (Date.now() - lowSpeedSince > 6000) {
|
||||||
if (speedAbort) speedAbort.abort();
|
speedAbort.abort();
|
||||||
clearInterval(speedMonitor);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lowSpeedSince = 0;
|
lowSpeedSince = 0;
|
||||||
@ -232,12 +274,11 @@ class UploadManager extends EventEmitter {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress callback with speed tracking
|
this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 });
|
||||||
|
|
||||||
const progressCb = (bytesUploaded, bytesTotal) => {
|
const progressCb = (bytesUploaded, bytesTotal) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const elapsed = Math.round((now - jobStart) / 1000);
|
const elapsed = Math.round((now - jobStart) / 1000);
|
||||||
|
|
||||||
// Speed calculation (update every ~1s)
|
|
||||||
const timeDelta = (now - lastSpeedTime) / 1000;
|
const timeDelta = (now - lastSpeedTime) / 1000;
|
||||||
if (timeDelta >= 1) {
|
if (timeDelta >= 1) {
|
||||||
const bytesDelta = bytesUploaded - lastBytes;
|
const bytesDelta = bytesUploaded - lastBytes;
|
||||||
@ -250,14 +291,21 @@ class UploadManager extends EventEmitter {
|
|||||||
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
|
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Update active job stats for global aggregation
|
this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded });
|
||||||
this.activeJobs.set(uploadId, { speedKbs: currentSpeedKbs, bytesUploaded });
|
|
||||||
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, {
|
||||||
status: 'uploading', progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
jobId,
|
||||||
bytesUploaded, bytesTotal: bytesTotal,
|
status: 'uploading',
|
||||||
speedKbs: currentSpeedKbs, elapsed, remaining,
|
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
||||||
error: null, result: null, attempt, maxAttempts
|
bytesUploaded,
|
||||||
|
bytesTotal,
|
||||||
|
speedKbs: currentSpeedKbs,
|
||||||
|
elapsed,
|
||||||
|
remaining,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
attempt,
|
||||||
|
maxAttempts
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -265,60 +313,45 @@ class UploadManager extends EventEmitter {
|
|||||||
if (task.hoster === 'vidmoly.me' && task.username) {
|
if (task.hoster === 'vidmoly.me' && task.username) {
|
||||||
const vidmoly = new VidmolyUploader();
|
const vidmoly = new VidmolyUploader();
|
||||||
await vidmoly.login(task.username, task.password);
|
await vidmoly.login(task.username, task.password);
|
||||||
result = await vidmoly.upload(task.file, progressCb, jobSignal, throttle);
|
result = await vidmoly.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
} else if (task.hoster === 'voe.sx' && task.username) {
|
} else if (task.hoster === 'voe.sx' && task.username) {
|
||||||
const voe = new VoeUploader();
|
const voe = new VoeUploader();
|
||||||
await voe.login(task.username, task.password);
|
await voe.login(task.username, task.password);
|
||||||
result = await voe.upload(task.file, progressCb, jobSignal, throttle);
|
result = await voe.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
} else if (task.hoster === 'doodstream.com' && task.username) {
|
} else if (task.hoster === 'doodstream.com' && task.username) {
|
||||||
const dood = new DoodstreamUploader();
|
const dood = new DoodstreamUploader();
|
||||||
await dood.login(task.username, task.password);
|
await dood.login(task.username, task.password);
|
||||||
result = await dood.upload(task.file, progressCb, jobSignal, throttle);
|
result = await dood.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
} else {
|
} else {
|
||||||
result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle);
|
result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear speed monitor and signal listeners
|
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
||||||
if (speedMonitor) clearInterval(speedMonitor);
|
|
||||||
if (signalCleanup) signalCleanup();
|
|
||||||
|
|
||||||
// Track session bytes
|
|
||||||
this.sessionBytes += fileSize;
|
this.sessionBytes += fileSize;
|
||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
|
|
||||||
// Success
|
emitFinalStatus('done', {
|
||||||
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
result,
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
speedKbs: currentSpeedKbs,
|
||||||
status: 'done', progress: 1,
|
elapsed,
|
||||||
bytesUploaded: fileSize, bytesTotal: fileSize,
|
attempt
|
||||||
speedKbs: currentSpeedKbs, elapsed, remaining: 0,
|
|
||||||
error: null, result, attempt, maxAttempts
|
|
||||||
});
|
});
|
||||||
|
recordFinalResult('done', { result });
|
||||||
results.get(task.file).results.push({
|
return;
|
||||||
hoster: task.hoster, status: 'done', ...result
|
|
||||||
});
|
|
||||||
|
|
||||||
semaphore.release();
|
|
||||||
return; // Success — exit retry loop
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Clear speed monitor interval and signal listeners on error
|
|
||||||
if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; }
|
|
||||||
if (signalCleanup) { signalCleanup(); signalCleanup = null; }
|
|
||||||
if (speedAbort) {
|
|
||||||
// Check if this was a speed restart
|
|
||||||
try { speedAbort.abort(); } catch {}
|
|
||||||
}
|
|
||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
|
|
||||||
|
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
lastError = err;
|
lastError = new Error('Abgebrochen');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stopAfterActive) {
|
||||||
|
lastError = new Error('Angehalten');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if speed restart (not user abort)
|
|
||||||
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
|
|
||||||
if (isSpeedRestart && attempt < maxAttempts) {
|
if (isSpeedRestart && attempt < maxAttempts) {
|
||||||
lastError = new Error('Geschwindigkeit zu niedrig - Neustart');
|
lastError = new Error('Geschwindigkeit zu niedrig - Neustart');
|
||||||
continue;
|
continue;
|
||||||
@ -326,29 +359,39 @@ class UploadManager extends EventEmitter {
|
|||||||
|
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (attempt >= maxAttempts) break;
|
if (attempt >= maxAttempts) break;
|
||||||
|
} finally {
|
||||||
|
if (speedMonitor) clearInterval(speedMonitor);
|
||||||
|
uploadSignalBundle.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All attempts exhausted
|
const wasStopped = this.stopAfterActive && !signal.aborted;
|
||||||
|
const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId);
|
||||||
|
if (wasStopped || wasAborted) {
|
||||||
|
const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen';
|
||||||
|
emitFinalStatus('aborted', { error });
|
||||||
|
recordFinalResult('aborted', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
|
||||||
|
emitFinalStatus('error', { error });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
} catch (err) {
|
||||||
|
const wasStopped = this.stopAfterActive && !signal.aborted;
|
||||||
|
const error = wasStopped
|
||||||
|
? 'Warteschlange angehalten'
|
||||||
|
: (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler'));
|
||||||
|
const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error';
|
||||||
|
emitFinalStatus(status, { error });
|
||||||
|
recordFinalResult(status === 'error' ? 'error' : 'aborted', { error });
|
||||||
|
} finally {
|
||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
const errorMsg = signal.aborted
|
this.jobAbortControllers.delete(jobId);
|
||||||
? 'Abgebrochen'
|
cleanupSignals();
|
||||||
: (lastError ? lastError.message : 'Unbekannter Fehler');
|
if (hosterSlotAcquired) hosterSemaphore.release();
|
||||||
|
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
|
||||||
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) {
|
_emitProgress(uploadId, fileName, hoster, data) {
|
||||||
@ -365,20 +408,18 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
||||||
|
|
||||||
// Sum in-progress bytes for live total
|
|
||||||
let inProgressBytes = 0;
|
let inProgressBytes = 0;
|
||||||
for (const job of this.activeJobs.values()) {
|
for (const job of this.activeJobs.values()) {
|
||||||
inProgressBytes += job.bytesUploaded || 0;
|
inProgressBytes += job.bytesUploaded || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('stats', {
|
this.emit('stats', {
|
||||||
state: this.running ? 'uploading' : 'idle',
|
state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle',
|
||||||
globalSpeedKbs,
|
globalSpeedKbs,
|
||||||
totalBytes: this.sessionBytes + inProgressBytes,
|
totalBytes: this.sessionBytes + inProgressBytes,
|
||||||
elapsed,
|
elapsed,
|
||||||
activeJobs: activeCount,
|
activeJobs: activeCount,
|
||||||
pendingJobs: Object.values(this.semaphores).reduce((sum, s) => sum + s.pending, 0)
|
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@ -392,39 +433,101 @@ class UploadManager extends EventEmitter {
|
|||||||
|
|
||||||
_combineSignals(signal1, signal2) {
|
_combineSignals(signal1, signal2) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
if (signal1.aborted || signal2.aborted) { controller.abort(); return { signal: controller.signal, cleanup() {} }; }
|
if (signal1.aborted || signal2.aborted) {
|
||||||
|
controller.abort();
|
||||||
|
return { signal: controller.signal, cleanup() {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
controller.abort();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
signal1.removeEventListener('abort', onAbort);
|
signal1.removeEventListener('abort', onAbort);
|
||||||
signal2.removeEventListener('abort', onAbort);
|
signal2.removeEventListener('abort', onAbort);
|
||||||
};
|
};
|
||||||
const onAbort = () => { controller.abort(); cleanup(); };
|
|
||||||
signal1.addEventListener('abort', onAbort, { once: true });
|
signal1.addEventListener('abort', onAbort, { once: true });
|
||||||
signal2.addEventListener('abort', onAbort, { once: true });
|
signal2.addEventListener('abort', onAbort, { once: true });
|
||||||
return { signal: controller.signal, cleanup };
|
return { signal: controller.signal, cleanup };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_combineManySignals(signals) {
|
||||||
|
const liveSignals = signals.filter(Boolean);
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
if (liveSignals.some((signal) => signal.aborted)) {
|
||||||
|
controller.abort();
|
||||||
|
return { signal: controller.signal, cleanup() {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = liveSignals.map((signal) => {
|
||||||
|
const handler = () => {
|
||||||
|
controller.abort();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', handler, { once: true });
|
||||||
|
return { signal, handler };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const entry of listeners) {
|
||||||
|
entry.signal.removeEventListener('abort', entry.handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { signal: controller.signal, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
_sleep(ms, signal) {
|
_sleep(ms, signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')); };
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
};
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (signal) signal.removeEventListener('abort', onAbort);
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
resolve();
|
resolve();
|
||||||
}, ms);
|
}, ms);
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; }
|
if (signal.aborted) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancelJobs(jobIds) {
|
||||||
if (this.running) {
|
for (const jobId of jobIds || []) {
|
||||||
this.abortController.abort();
|
if (!jobId) continue;
|
||||||
this.running = false;
|
this.cancelledJobIds.add(jobId);
|
||||||
this._stopStatsTimer();
|
const controller = this.jobAbortControllers.get(jobId);
|
||||||
this.activeJobs.clear();
|
if (controller && !controller.signal.aborted) {
|
||||||
|
controller.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finishAfterActive() {
|
||||||
|
this.stopAfterActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
if (!this.running) return;
|
||||||
|
this.abortController.abort();
|
||||||
|
this.stopAfterActive = false;
|
||||||
|
this.running = false;
|
||||||
|
for (const controller of this.jobAbortControllers.values()) {
|
||||||
|
if (!controller.signal.aborted) controller.abort();
|
||||||
|
}
|
||||||
|
this._stopStatsTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = UploadManager;
|
module.exports = UploadManager;
|
||||||
|
|||||||
92
main.js
92
main.js
@ -126,6 +126,46 @@ function buildUploadTasks(config, files, hosters) {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUploadTasksFromJobs(config, jobs) {
|
||||||
|
if (!Array.isArray(jobs)) return [];
|
||||||
|
|
||||||
|
return jobs.flatMap((job) => {
|
||||||
|
if (!job || !job.file || !job.hoster) return [];
|
||||||
|
const hosterConfig = config.hosters[job.hoster];
|
||||||
|
if (!hosterConfig) {
|
||||||
|
debugLog(` skip ${job.hoster}: no config for queued job`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTask = {
|
||||||
|
jobId: job.id || job.jobId || null,
|
||||||
|
file: job.file,
|
||||||
|
hoster: job.hoster
|
||||||
|
};
|
||||||
|
|
||||||
|
if (job.hoster === 'vidmoly.me') {
|
||||||
|
if (!hosterConfig.username || !hosterConfig.password) {
|
||||||
|
debugLog(` skip ${job.hoster}: missing username/password`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ ...baseTask, username: hosterConfig.username, password: hosterConfig.password }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((job.hoster === 'voe.sx' || job.hoster === 'doodstream.com') && hosterConfig.username && hosterConfig.password) {
|
||||||
|
debugLog(` task: ${job.hoster} queued login=${hosterConfig.username.slice(0, 6)}...`);
|
||||||
|
return [{ ...baseTask, username: hosterConfig.username, password: hosterConfig.password, apiKey: hosterConfig.apiKey || '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hosterConfig.apiKey) {
|
||||||
|
debugLog(` skip ${job.hoster}: missing apiKey`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog(` task: ${job.hoster} queued key=${hosterConfig.apiKey.slice(0, 6)}...`);
|
||||||
|
return [{ ...baseTask, apiKey: hosterConfig.apiKey }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function checkDoodstreamHealth(hosterConfig) {
|
async function checkDoodstreamHealth(hosterConfig) {
|
||||||
const username = hosterConfig && hosterConfig.username
|
const username = hosterConfig && hosterConfig.username
|
||||||
? String(hosterConfig.username).trim()
|
? String(hosterConfig.username).trim()
|
||||||
@ -158,7 +198,7 @@ async function checkDoodstreamHealth(hosterConfig) {
|
|||||||
});
|
});
|
||||||
const accountPayload = await accountRes.json().catch(() => null);
|
const accountPayload = await accountRes.json().catch(() => null);
|
||||||
if (!accountPayload || typeof accountPayload !== 'object') {
|
if (!accountPayload || typeof accountPayload !== 'object') {
|
||||||
return { status: 'error', message: 'Account-Check lieferte kein gueltiges JSON' };
|
return { status: 'error', message: 'Account-Check lieferte kein gültiges JSON' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(accountPayload.status || 0) !== 200) {
|
if (Number(accountPayload.status || 0) !== 200) {
|
||||||
@ -174,25 +214,25 @@ async function checkDoodstreamHealth(hosterConfig) {
|
|||||||
});
|
});
|
||||||
const serverPayload = await serverRes.json().catch(() => null);
|
const serverPayload = await serverRes.json().catch(() => null);
|
||||||
if (!serverPayload || typeof serverPayload !== 'object') {
|
if (!serverPayload || typeof serverPayload !== 'object') {
|
||||||
return { status: 'warn', message: 'Upload-Server-Check lieferte kein gueltiges JSON' };
|
return { status: 'warn', message: 'Upload-Server-Check lieferte kein gültiges JSON' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverResult = serverPayload.result;
|
const serverResult = serverPayload.result;
|
||||||
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
|
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
|
||||||
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
|
return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim();
|
const serverMsg = String(serverPayload.msg || serverPayload.message || '').trim();
|
||||||
if (/no servers available/i.test(serverMsg)) {
|
if (/no servers available/i.test(serverMsg)) {
|
||||||
return {
|
return {
|
||||||
status: 'warn',
|
status: 'warn',
|
||||||
message: 'API Key gueltig, aktuell kein Server von API (Uploader nutzt Fallback)'
|
message: 'API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'warn',
|
status: 'warn',
|
||||||
message: serverMsg || 'API Key gueltig, Upload-Server aktuell nicht geliefert'
|
message: serverMsg || 'API Key gültig, Upload-Server aktuell nicht geliefert'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,13 +282,13 @@ async function checkVoeHealth(hosterConfig) {
|
|||||||
const res = await fetch(`https://voe.sx/api/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET' });
|
const res = await fetch(`https://voe.sx/api/upload/server?key=${encodeURIComponent(apiKey)}`, { method: 'GET' });
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (data && data.result && typeof data.result === 'string' && /^https?:\/\//i.test(data.result.trim())) {
|
if (data && data.result && typeof data.result === 'string' && /^https?:\/\//i.test(data.result.trim())) {
|
||||||
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
|
return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' };
|
||||||
}
|
}
|
||||||
const msg = data && (data.msg || data.message) ? String(data.msg || data.message).trim() : '';
|
const msg = data && (data.msg || data.message) ? String(data.msg || data.message).trim() : '';
|
||||||
if (/no servers/i.test(msg)) {
|
if (/no servers/i.test(msg)) {
|
||||||
return { status: 'warn', message: 'API Key gueltig, aktuell kein Server verfuegbar' };
|
return { status: 'warn', message: 'API Key gültig, aktuell kein Server verfügbar' };
|
||||||
}
|
}
|
||||||
return { status: 'error', message: msg || 'API Key ungueltig oder Server nicht erreichbar' };
|
return { status: 'error', message: msg || 'API Key ungültig oder Server nicht erreichbar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploader = new VoeUploader();
|
const uploader = new VoeUploader();
|
||||||
@ -283,12 +323,12 @@ async function checkByseHealth(hosterConfig) {
|
|||||||
const serverPayload = await serverRes.json().catch(() => null);
|
const serverPayload = await serverRes.json().catch(() => null);
|
||||||
|
|
||||||
if (!serverPayload || typeof serverPayload !== 'object') {
|
if (!serverPayload || typeof serverPayload !== 'object') {
|
||||||
return { status: 'error', message: 'API lieferte kein gueltiges JSON' };
|
return { status: 'error', message: 'API lieferte kein gültiges JSON' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverResult = serverPayload.result;
|
const serverResult = serverPayload.result;
|
||||||
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
|
if (typeof serverResult === 'string' && /^https?:\/\//i.test(serverResult.trim())) {
|
||||||
return { status: 'ok', message: 'API Key gueltig, Upload-Server verfuegbar' };
|
return { status: 'ok', message: 'API Key gültig, Upload-Server verfügbar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = String(serverPayload.msg || serverPayload.message || '').trim();
|
const msg = String(serverPayload.msg || serverPayload.message || '').trim();
|
||||||
@ -296,7 +336,7 @@ async function checkByseHealth(hosterConfig) {
|
|||||||
return { status: 'error', message: msg };
|
return { status: 'error', message: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 'error', message: 'API Key ungueltig oder Server nicht erreichbar' };
|
return { status: 'error', message: 'API Key ungültig oder Server nicht erreichbar' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runHosterHealthCheck(config, requestedHosters) {
|
async function runHosterHealthCheck(config, requestedHosters) {
|
||||||
@ -478,18 +518,22 @@ ipcMain.handle('select-folder', async () => {
|
|||||||
|
|
||||||
ipcMain.handle('start-upload', (_event, payload) => {
|
ipcMain.handle('start-upload', (_event, payload) => {
|
||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
const { files, hosters } = payload;
|
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
||||||
|
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
|
||||||
|
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
|
||||||
|
|
||||||
debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}`);
|
debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}, jobs=${jobs.length}`);
|
||||||
|
|
||||||
const tasks = buildUploadTasks(config, files, hosters);
|
const tasks = jobs.length > 0
|
||||||
|
? buildUploadTasksFromJobs(config, jobs)
|
||||||
|
: buildUploadTasks(config, files, hosters);
|
||||||
|
|
||||||
debugLog(` tasks built: ${tasks.length}`);
|
debugLog(` tasks built: ${tasks.length}`);
|
||||||
|
|
||||||
if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' };
|
if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.' };
|
||||||
|
|
||||||
// Pass hoster settings to the upload manager
|
// Pass hoster settings to the upload manager
|
||||||
uploadManager = new UploadManager(config.hosterSettings || {});
|
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
|
||||||
|
|
||||||
uploadManager.on('progress', (data) => {
|
uploadManager.on('progress', (data) => {
|
||||||
// Only log state changes, not continuous progress updates
|
// Only log state changes, not continuous progress updates
|
||||||
@ -528,6 +572,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
|
|
||||||
// Shutdown after finish
|
// Shutdown after finish
|
||||||
handleShutdownAfterFinish();
|
handleShutdownAfterFinish();
|
||||||
|
uploadManager = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer startBatch to next tick so the IPC response is sent first.
|
// Defer startBatch to next tick so the IPC response is sent first.
|
||||||
@ -559,7 +604,20 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
ipcMain.handle('cancel-upload', () => {
|
ipcMain.handle('cancel-upload', () => {
|
||||||
if (uploadManager) {
|
if (uploadManager) {
|
||||||
uploadManager.cancel();
|
uploadManager.cancel();
|
||||||
uploadManager = null;
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => {
|
||||||
|
if (uploadManager) {
|
||||||
|
uploadManager.cancelJobs(Array.isArray(jobIds) ? jobIds : []);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('finish-after-active', () => {
|
||||||
|
if (uploadManager) {
|
||||||
|
uploadManager.finishAfterActive();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "node --test tests/",
|
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||||
"dist": "electron-builder --win",
|
"dist": "electron-builder --win",
|
||||||
"release:win": "electron-builder --publish never --win nsis portable",
|
"release:win": "electron-builder --publish never --win nsis portable",
|
||||||
"release:gitea": "node scripts/release_gitea.mjs"
|
"release:gitea": "node scripts/release_gitea.mjs"
|
||||||
|
|||||||
@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Upload control
|
// Upload control
|
||||||
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
|
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
|
||||||
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
||||||
|
cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds),
|
||||||
|
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
||||||
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
|
|||||||
698
renderer/app.js
698
renderer/app.js
File diff suppressed because it is too large
Load Diff
@ -21,59 +21,54 @@
|
|||||||
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</button>
|
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload View -->
|
|
||||||
<div id="upload-view" class="view active">
|
<div id="upload-view" class="view active">
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="upload-toolbar">
|
<div class="upload-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<button class="btn btn-xs btn-secondary" id="chooseHostersBtn">Ziele waehlen</button>
|
<button class="btn btn-xs btn-secondary" id="chooseHostersBtn">Ziele auswählen</button>
|
||||||
<span class="hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewaehlt</span>
|
<span class="hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<div class="health-check-inline">
|
|
||||||
<button class="btn btn-xs btn-secondary" id="runHealthCheckBtn" title="Hoster Check">Check</button>
|
|
||||||
<label class="auto-check-label" title="Auto-Check vor Upload">
|
|
||||||
<input type="checkbox" id="autoHealthCheckToggle" checked>
|
|
||||||
<span>Auto</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Health check results (collapsible) -->
|
|
||||||
<div class="health-check-results" id="healthCheckResults"></div>
|
|
||||||
|
|
||||||
<div class="upload-workspace">
|
<div class="upload-workspace">
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Queue Table -->
|
|
||||||
<div class="queue-shell" id="queueShell" style="display:none">
|
<div class="queue-shell" id="queueShell" style="display:none">
|
||||||
|
<div class="queue-command-bar" id="queueCommandBar">
|
||||||
|
<button class="btn btn-xs btn-success" id="startUploadBtn" disabled>Start Uploading</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="reuploadSelectedBtn">Reupload selected file</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="abortSelectedBtn">Abort selected file</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="finishStopBtn">Finish Uploads in Progress and Stop</button>
|
||||||
|
<button class="btn btn-xs btn-danger" id="abortAllBtn">Abort all Downloads</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="moveTopBtn">Move to the top</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="moveUpBtn">Move up</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="moveDownBtn">Move down</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="moveBottomBtn">Move to the bottom</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="queue-container" id="queueContainer">
|
<div class="queue-container" id="queueContainer">
|
||||||
<table class="queue-table" id="queueTable">
|
<table class="queue-table" id="queueTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-filename sortable" data-sort="filename">File name</th>
|
<th class="col-filename sortable" data-sort="filename">Dateiname</th>
|
||||||
<th class="col-size sortable" data-sort="size">Uploaded / Size</th>
|
<th class="col-size sortable" data-sort="size">Hochgeladen / Größe</th>
|
||||||
<th class="col-host sortable" data-sort="host">Host</th>
|
<th class="col-host sortable" data-sort="host">Host</th>
|
||||||
<th class="col-status sortable" data-sort="status">Status</th>
|
<th class="col-status sortable" data-sort="status">Status</th>
|
||||||
<th class="col-elapsed">Elapsed</th>
|
<th class="col-elapsed">Zeit</th>
|
||||||
<th class="col-remaining">Remain.</th>
|
<th class="col-remaining">Rest</th>
|
||||||
<th class="col-speed sortable" data-sort="speed">Speed</th>
|
<th class="col-speed sortable" data-sort="speed">Speed</th>
|
||||||
<th class="col-progress">Progress</th>
|
<th class="col-progress">Fortschritt</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="queueBody"></tbody>
|
<tbody id="queueBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action bar below queue -->
|
|
||||||
<div class="queue-actions" id="queueActions" style="display:none">
|
<div class="queue-actions" id="queueActions" style="display:none">
|
||||||
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
<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>
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
@ -88,8 +83,8 @@
|
|||||||
<table class="recent-files-table">
|
<table class="recent-files-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-date">Date</th>
|
<th class="col-date">Datum</th>
|
||||||
<th class="col-filename">Filename</th>
|
<th class="col-filename">Dateiname</th>
|
||||||
<th class="col-host">Host</th>
|
<th class="col-host">Host</th>
|
||||||
<th class="col-link">Link</th>
|
<th class="col-link">Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -102,29 +97,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accounts View -->
|
|
||||||
<div id="accounts-view" class="view">
|
<div id="accounts-view" class="view">
|
||||||
<div class="accounts-container">
|
<div class="accounts-container">
|
||||||
<div class="accounts-header">
|
<div class="accounts-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Accounts</h2>
|
<h2>Accounts</h2>
|
||||||
<p class="settings-hint">Hoster-Zugangsdaten verwalten</p>
|
<p class="settings-hint">Hoster-Zugangsdaten verwalten und prüfen</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufuegen</button>
|
<div class="accounts-header-actions">
|
||||||
|
<button class="btn btn-secondary" id="accountsRunHealthCheckBtn">Accounts prüfen</button>
|
||||||
|
<label class="auto-check-label accounts-auto-check" title="Automatischer Check vor dem Upload">
|
||||||
|
<input type="checkbox" id="autoHealthCheckToggle" checked>
|
||||||
|
<span>Auto-Check vor Upload</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
|
||||||
<div class="accounts-list" id="accountsList"></div>
|
<div class="accounts-list" id="accountsList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Modal -->
|
|
||||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="accountModalTitle">Account hinzufuegen</h3>
|
<h3 id="accountModalTitle">Account hinzufügen</h3>
|
||||||
<p id="accountModalSubtitle">Waehle einen Hoster und gib deine Zugangsdaten ein.</p>
|
<p id="accountModalSubtitle">Wähle einen Hoster und gib deine Zugangsdaten ein.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" id="closeAccountModalBtn" aria-label="Schliessen">×</button>
|
<button class="icon-btn" id="closeAccountModalBtn" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="settings-row" id="accountHosterRow">
|
<div class="settings-row" id="accountHosterRow">
|
||||||
@ -136,53 +137,49 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
||||||
<button class="btn btn-primary" id="saveAccountBtn">Anlegen & Pruefen</button>
|
<button class="btn btn-primary" id="saveAccountBtn">Anlegen & prüfen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirm Modal -->
|
|
||||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
||||||
<div class="modal-card" style="width:min(400px,100%)">
|
<div class="modal-card" style="width:min(400px,100%)">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div><h3>Account loeschen?</h3></div>
|
<div><h3>Account löschen?</h3></div>
|
||||||
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schliessen">×</button>
|
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p id="deleteAccountMessage">Account wirklich loeschen?</p>
|
<p id="deleteAccountMessage">Account wirklich löschen?</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="cancelDeleteBtn">Abbrechen</button>
|
<button class="btn btn-secondary" id="cancelDeleteBtn">Abbrechen</button>
|
||||||
<button class="btn btn-danger" id="confirmDeleteBtn">Loeschen</button>
|
<button class="btn btn-danger" id="confirmDeleteBtn">Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings View -->
|
|
||||||
<div id="settings-view" class="view">
|
<div id="settings-view" class="view">
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<h2>Upload Einstellungen</h2>
|
<h2>Upload-Einstellungen</h2>
|
||||||
<p class="settings-hint">Upload-Einstellungen pro Hoster. Zugangsdaten werden im Accounts-Tab verwaltet.</p>
|
<p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p>
|
||||||
<div class="settings-hosters" id="settingsHosters"></div>
|
<div class="settings-hosters" id="settingsHosters"></div>
|
||||||
<div class="settings-save-row">
|
<div class="settings-save-row">
|
||||||
<span class="save-feedback" id="saveFeedback"></span>
|
<span class="save-feedback" id="saveFeedback">Änderungen werden automatisch gespeichert.</span>
|
||||||
<button class="btn btn-primary" id="saveSettingsBtn">Alles Speichern</button>
|
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History View -->
|
|
||||||
<div id="history-view" class="view">
|
<div id="history-view" class="view">
|
||||||
<div class="history-container">
|
<div class="history-container">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h2>Upload Verlauf</h2>
|
<h2>Upload-Verlauf</h2>
|
||||||
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf loeschen</button>
|
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="historyContainer"></div>
|
<div id="historyContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
|
||||||
<div class="context-menu" id="contextMenu" style="display:none">
|
<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="copy-links">Links kopieren</div>
|
||||||
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
||||||
@ -202,7 +199,6 @@
|
|||||||
<div class="ctx-item" data-action="always-on-top">Immer im Vordergrund</div>
|
<div class="ctx-item" data-action="always-on-top">Immer im Vordergrund</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statusbar -->
|
|
||||||
<div class="statusbar" id="statusbar">
|
<div class="statusbar" id="statusbar">
|
||||||
<span class="sb-state" id="sbState">Bereit</span>
|
<span class="sb-state" id="sbState">Bereit</span>
|
||||||
<span class="sb-separator">|</span>
|
<span class="sb-separator">|</span>
|
||||||
@ -210,13 +206,21 @@
|
|||||||
<span class="sb-separator">|</span>
|
<span class="sb-separator">|</span>
|
||||||
<span class="sb-total" id="sbTotal">0 B</span>
|
<span class="sb-total" id="sbTotal">0 B</span>
|
||||||
<span class="sb-separator">|</span>
|
<span class="sb-separator">|</span>
|
||||||
<span class="sb-elapsed" id="sbElapsed">00:00:00</span>
|
<span class="sb-eta" id="sbEta">ETA --:--</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-connections" id="sbConnections">Aktive Verbindungen 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-remaining-count" id="sbRemainingCount">Remaining 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-error-count" id="sbErrorCount">Error 0</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Copy toast -->
|
|
||||||
<div class="copy-toast" id="copyToast"></div>
|
<div class="copy-toast" id="copyToast"></div>
|
||||||
|
|
||||||
<!-- Shutdown countdown -->
|
|
||||||
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
|
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
|
||||||
<div class="shutdown-box">
|
<div class="shutdown-box">
|
||||||
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
||||||
@ -228,10 +232,10 @@
|
|||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Upload-Ziele auswaehlen</h3>
|
<h3>Upload-Ziele auswählen</h3>
|
||||||
<p>Dateien wurden hinzugefuegt. Waehle jetzt die Hoster fuer den Upload.</p>
|
<p>Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" id="closeHosterModalBtn" aria-label="Schliessen">×</button>
|
<button class="icon-btn" id="closeHosterModalBtn" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="modal-actions-inline">
|
<div class="modal-actions-inline">
|
||||||
@ -243,7 +247,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="cancelHosterModalBtn">Abbrechen</button>
|
<button class="btn btn-secondary" id="cancelHosterModalBtn">Abbrechen</button>
|
||||||
<button class="btn btn-primary" id="confirmHosterModalBtn">Uebernehmen</button>
|
<button class="btn btn-primary" id="confirmHosterModalBtn">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -175,6 +175,14 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.queue-command-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(0,0,0,0.12));
|
||||||
|
}
|
||||||
.queue-container {
|
.queue-container {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -240,6 +248,7 @@ body {
|
|||||||
.queue-row.status-retrying { background: rgba(253, 203, 110, 0.08); }
|
.queue-row.status-retrying { background: rgba(253, 203, 110, 0.08); }
|
||||||
.queue-row.status-done { background: rgba(0, 184, 148, 0.06); }
|
.queue-row.status-done { background: rgba(0, 184, 148, 0.06); }
|
||||||
.queue-row.status-error { background: rgba(231, 76, 60, 0.1); }
|
.queue-row.status-error { background: rgba(231, 76, 60, 0.1); }
|
||||||
|
.queue-row.status-aborted { background: rgba(240, 195, 108, 0.08); }
|
||||||
.queue-row.status-skipped { background: rgba(255, 255, 255, 0.02); opacity: 0.6; }
|
.queue-row.status-skipped { background: rgba(255, 255, 255, 0.02); opacity: 0.6; }
|
||||||
|
|
||||||
/* Status Badge */
|
/* Status Badge */
|
||||||
@ -257,6 +266,7 @@ body {
|
|||||||
.status-badge.status-retrying { color: var(--warning); background: rgba(253, 203, 110, 0.15); }
|
.status-badge.status-retrying { color: var(--warning); background: rgba(253, 203, 110, 0.15); }
|
||||||
.status-badge.status-done { color: var(--success); background: rgba(0, 184, 148, 0.15); }
|
.status-badge.status-done { color: var(--success); background: rgba(0, 184, 148, 0.15); }
|
||||||
.status-badge.status-error { color: var(--danger); background: rgba(231, 76, 60, 0.15); }
|
.status-badge.status-error { color: var(--danger); background: rgba(231, 76, 60, 0.15); }
|
||||||
|
.status-badge.status-aborted { color: var(--warning); background: rgba(240, 195, 108, 0.16); }
|
||||||
.status-badge.status-skipped { color: var(--text-dim); }
|
.status-badge.status-skipped { color: var(--text-dim); }
|
||||||
|
|
||||||
/* Progress in table cell */
|
/* Progress in table cell */
|
||||||
@ -278,6 +288,7 @@ body {
|
|||||||
.progress-bar-fill.status-retrying { background: var(--warning); }
|
.progress-bar-fill.status-retrying { background: var(--warning); }
|
||||||
.progress-bar-fill.status-done { background: linear-gradient(90deg, var(--success), var(--success-end)); }
|
.progress-bar-fill.status-done { background: linear-gradient(90deg, var(--success), var(--success-end)); }
|
||||||
.progress-bar-fill.status-error { background: var(--danger); }
|
.progress-bar-fill.status-error { background: var(--danger); }
|
||||||
|
.progress-bar-fill.status-aborted { background: linear-gradient(90deg, #e0b458, #f0c36c); }
|
||||||
.progress-pct { font-size: 10px; color: var(--text-muted); min-width: 28px; text-align: right; }
|
.progress-pct { font-size: 10px; color: var(--text-muted); min-width: 28px; text-align: right; }
|
||||||
|
|
||||||
/* Queue Actions */
|
/* Queue Actions */
|
||||||
@ -574,6 +585,14 @@ body {
|
|||||||
|
|
||||||
.settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; }
|
.settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
.save-feedback { font-size: 12px; color: var(--success); }
|
.save-feedback { font-size: 12px; color: var(--success); }
|
||||||
|
.settings-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-grid-mini {
|
.settings-grid-mini {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -583,8 +602,25 @@ body {
|
|||||||
|
|
||||||
/* Accounts View */
|
/* Accounts View */
|
||||||
.accounts-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
|
.accounts-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
|
||||||
.accounts-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
.accounts-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
||||||
.accounts-header h2 { font-size: 18px; margin-bottom: 2px; }
|
.accounts-header h2 { font-size: 18px; margin-bottom: 2px; }
|
||||||
|
.accounts-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.accounts-auto-check {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.account-health-results {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
.accounts-list { display: grid; gap: 8px; }
|
.accounts-list { display: grid; gap: 8px; }
|
||||||
|
|
||||||
.account-card {
|
.account-card {
|
||||||
@ -616,6 +652,7 @@ body {
|
|||||||
.account-status.status-ok { background: rgba(0, 184, 148, 0.2); color: var(--success); }
|
.account-status.status-ok { background: rgba(0, 184, 148, 0.2); color: var(--success); }
|
||||||
.account-status.status-checking { background: rgba(253, 203, 110, 0.2); color: var(--warning); }
|
.account-status.status-checking { background: rgba(253, 203, 110, 0.2); color: var(--warning); }
|
||||||
.account-status.status-error { background: rgba(231, 76, 60, 0.2); color: var(--danger); }
|
.account-status.status-error { background: rgba(231, 76, 60, 0.2); color: var(--danger); }
|
||||||
|
.account-status.status-warn { background: rgba(240, 195, 108, 0.2); color: var(--warning); }
|
||||||
.account-status.status-unchecked { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); }
|
.account-status.status-unchecked { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); }
|
||||||
|
|
||||||
.account-status-dot {
|
.account-status-dot {
|
||||||
@ -627,6 +664,7 @@ body {
|
|||||||
.status-ok .account-status-dot { background: var(--success); }
|
.status-ok .account-status-dot { background: var(--success); }
|
||||||
.status-checking .account-status-dot { background: var(--warning); }
|
.status-checking .account-status-dot { background: var(--warning); }
|
||||||
.status-error .account-status-dot { background: var(--danger); }
|
.status-error .account-status-dot { background: var(--danger); }
|
||||||
|
.status-warn .account-status-dot { background: var(--warning); }
|
||||||
.status-unchecked .account-status-dot { background: var(--text-dim); }
|
.status-unchecked .account-status-dot { background: var(--text-dim); }
|
||||||
|
|
||||||
.account-modal-status {
|
.account-modal-status {
|
||||||
@ -692,6 +730,7 @@ body {
|
|||||||
.statusbar {
|
.statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
background: #0a0a14;
|
background: #0a0a14;
|
||||||
@ -703,7 +742,12 @@ body {
|
|||||||
.sb-separator { color: var(--text-dim); }
|
.sb-separator { color: var(--text-dim); }
|
||||||
.sb-speed { color: var(--link-color); font-weight: 500; }
|
.sb-speed { color: var(--link-color); font-weight: 500; }
|
||||||
.sb-total { color: var(--text); }
|
.sb-total { color: var(--text); }
|
||||||
.sb-elapsed { color: var(--text-muted); }
|
.sb-eta,
|
||||||
|
.sb-connections,
|
||||||
|
.sb-queue-count,
|
||||||
|
.sb-remaining-count,
|
||||||
|
.sb-progress-count,
|
||||||
|
.sb-error-count { color: var(--text-muted); }
|
||||||
.sb-state { flex: 1; }
|
.sb-state { flex: 1; }
|
||||||
|
|
||||||
/* Copy toast */
|
/* Copy toast */
|
||||||
@ -750,6 +794,15 @@ body {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
.queue-command-bar {
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.accounts-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.accounts-header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars */
|
/* Scrollbars */
|
||||||
|
|||||||
@ -44,12 +44,14 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
||||||
assert.equal(config.globalSettings.logFilePath, '');
|
assert.equal(config.globalSettings.logFilePath, '');
|
||||||
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
||||||
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
||||||
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||||
assert.equal(config.globalSettings.pendingQueue, null);
|
assert.equal(config.globalSettings.pendingQueue, null);
|
||||||
assert.deepEqual(config.history, []);
|
assert.deepEqual(config.history, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('save then load round-trips', () => {
|
it('save then load round-trips', async () => {
|
||||||
store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } } });
|
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } } });
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.equal(config.hosters['doodstream.com'].apiKey, 'test-key-123');
|
assert.equal(config.hosters['doodstream.com'].apiKey, 'test-key-123');
|
||||||
});
|
});
|
||||||
@ -78,20 +80,20 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
||||||
});
|
});
|
||||||
|
|
||||||
it('save only updates provided sections', () => {
|
it('save only updates provided sections', async () => {
|
||||||
// Save hoster settings first
|
// Save hoster settings first
|
||||||
store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
||||||
// Save hosters credentials separately
|
// Save hosters credentials separately
|
||||||
store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'key123' } } });
|
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'key123' } } });
|
||||||
|
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.equal(config.hosters['doodstream.com'].apiKey, 'key123');
|
assert.equal(config.hosters['doodstream.com'].apiKey, 'key123');
|
||||||
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appendHistory adds entries and caps at 100', () => {
|
it('appendHistory adds entries and caps at 100', async () => {
|
||||||
for (let i = 0; i < 105; i++) {
|
for (let i = 0; i < 105; i++) {
|
||||||
store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
||||||
}
|
}
|
||||||
const history = store.loadHistory();
|
const history = store.loadHistory();
|
||||||
assert.equal(history.length, 100);
|
assert.equal(history.length, 100);
|
||||||
@ -99,10 +101,10 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(history[99].id, 'batch-104');
|
assert.equal(history[99].id, 'batch-104');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearHistory empties the array', () => {
|
it('clearHistory empties the array', async () => {
|
||||||
store.appendHistory({ id: 'test', files: [] });
|
await store.appendHistory({ id: 'test', files: [] });
|
||||||
assert.equal(store.loadHistory().length, 1);
|
assert.equal(store.loadHistory().length, 1);
|
||||||
store.clearHistory();
|
await store.clearHistory();
|
||||||
assert.equal(store.loadHistory().length, 0);
|
assert.equal(store.loadHistory().length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,6 +125,8 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default
|
||||||
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
||||||
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
||||||
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||||
assert.equal(config.globalSettings.logFilePath, '');
|
assert.equal(config.globalSettings.logFilePath, '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,12 @@
|
|||||||
* Run with: node tests/ui-smoke.js
|
* Run with: node tests/ui-smoke.js
|
||||||
* (This spawns Electron as a child process)
|
* (This spawns Electron as a child process)
|
||||||
*/
|
*/
|
||||||
|
if (!process.env.RUN_UI_SMOKE) {
|
||||||
|
const { test } = require('node:test');
|
||||||
|
test('ui smoke skipped unless RUN_UI_SMOKE=1', () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|||||||
@ -181,6 +181,67 @@ describe('UploadManager', () => {
|
|||||||
assert.equal(maxConcurrent, 1, 'should only run 1 upload at a time');
|
assert.equal(maxConcurrent, 1, 'should only run 1 upload at a time');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('global parallel limit caps concurrency across hosters', async () => {
|
||||||
|
let concurrent = 0;
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
concurrent++;
|
||||||
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
concurrent--;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: `https://${hoster}/ok`, embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager({
|
||||||
|
'doodstream.com': { retries: 0, parallelCount: 5, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 },
|
||||||
|
'voe.sx': { retries: 0, parallelCount: 5, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 },
|
||||||
|
'vidmoly.me': { retries: 0, parallelCount: 5, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||||
|
}, {
|
||||||
|
parallelUploadCount: 2,
|
||||||
|
scaleParallelUploads: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ jobId: 'job-1', file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ jobId: 'job-2', file: '/test/b.mp4', hoster: 'voe.sx', apiKey: 'k' },
|
||||||
|
{ jobId: 'job-3', file: '/test/c.mp4', hoster: 'vidmoly.me', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(maxConcurrent, 2, 'should only run 2 uploads globally at once');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelJobs aborts a selected running upload', async () => {
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => resolve(), 250);
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const statuses = [];
|
||||||
|
mgr.on('progress', (data) => statuses.push({ jobId: data.jobId, status: data.status }));
|
||||||
|
|
||||||
|
const batchPromise = mgr.startBatch([
|
||||||
|
{ jobId: 'selected-job', file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
mgr.cancelJobs(['selected-job']);
|
||||||
|
|
||||||
|
await batchPromise;
|
||||||
|
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
|
||||||
|
});
|
||||||
|
|
||||||
it('_combineSignals propagates abort from either source', () => {
|
it('_combineSignals propagates abort from either source', () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
const ac1 = new AbortController();
|
const ac1 = new AbortController();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user