From d53eea443eead69fa27aaeb8d59c3a6f86552762 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 12 Mar 2026 05:00:33 +0100 Subject: [PATCH] feat: multi-account support with primary/fallback and separate API/login types - Multiple accounts per hoster with drag-sortable priority (primary + fallbacks) - Separate account types: Web Login and API selectable per hoster - Account fallback: after all retries fail, automatically switches to next fallback account - Fix: Byse health check returning [Fehler] OK when API responds with msg "OK" - Fix: retry during active upload sets status to "Wartet" instead of "Bereit" - Config migration from single-object to multi-account array format Co-Authored-By: Claude Opus 4.6 --- lib/config-store.js | 75 +++++- lib/upload-manager.js | 118 ++++++++-- main.js | 241 +++++++++---------- package.json | 2 +- preload.js | 6 + renderer/app.js | 528 ++++++++++++++++++++++++++---------------- renderer/styles.css | 38 +++ 7 files changed, 665 insertions(+), 343 deletions(-) diff --git a/lib/config-store.js b/lib/config-store.js index a11d5e7..68ea488 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -10,12 +10,35 @@ const HOSTER_SETTINGS_DEFAULTS = { maxSizeMb: 0 // 0 = unlimited }; +// Template for each hoster type (used as defaults for new accounts) +const HOSTER_ACCOUNT_TEMPLATES = { + 'doodstream.com': { enabled: true, authType: 'login', username: '', password: '' }, + 'doodstream.com:api': { enabled: true, authType: 'api', apiKey: '' }, + 'voe.sx': { enabled: true, authType: 'login', username: '', password: '' }, + 'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' }, + 'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, + 'byse.sx': { enabled: true, authType: 'api', apiKey: '' } +}; + +// All known hoster names (used for iteration) +const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; + +// Dropdown options for "Add Account" modal: value -> label +const HOSTER_ADD_OPTIONS = [ + { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, + { value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' }, + { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, + { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, + { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, + { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' } +]; + const DEFAULTS = { hosters: { - 'doodstream.com': { enabled: true, apiKey: '', username: '', password: '' }, - 'voe.sx': { enabled: true, apiKey: '' }, - 'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, - 'byse.sx': { enabled: true, apiKey: '' } + 'doodstream.com': [], + 'voe.sx': [], + 'vidmoly.me': [], + 'byse.sx': [] }, hosterSettings: { 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS }, @@ -113,13 +136,46 @@ class ConfigStore { } if (!data) return JSON.parse(JSON.stringify(DEFAULTS)); - // Merge with defaults so new hosters are always present - const hosters = { ...DEFAULTS.hosters }; + // Migrate old single-object format to array format for (const [name, val] of Object.entries(data.hosters || {})) { - if (hosters[name]) { - hosters[name] = { ...hosters[name], ...val }; + if (val && !Array.isArray(val)) { + if (!val.id) val.id = `${name}-migrated-${Date.now()}`; + // Infer authType for old format accounts + if (!val.authType) { + if (name === 'byse.sx') val.authType = 'api'; + else if (name === 'vidmoly.me') val.authType = 'login'; + else if (val.username && val.password) val.authType = 'login'; + else if (val.apiKey) val.authType = 'api'; + else val.authType = 'login'; + } + data.hosters[name] = [val]; } } + + // Merge hosters: ensure all known hosters exist as arrays + const hosters = {}; + for (const name of HOSTER_NAMES) { + const saved = data.hosters && data.hosters[name]; + if (Array.isArray(saved) && saved.length > 0) { + hosters[name] = saved.map((acc, i) => { + // Ensure authType is set on every account + if (!acc.authType) { + if (name === 'byse.sx') acc.authType = 'api'; + else if (name === 'vidmoly.me') acc.authType = 'login'; + else if (acc.username && acc.password) acc.authType = 'login'; + else if (acc.apiKey) acc.authType = 'api'; + else acc.authType = 'login'; + } + return { + ...acc, + id: acc.id || `${name}-${Date.now()}-${i}` + }; + }); + } else { + hosters[name] = []; + } + } + // Merge hoster settings with defaults const hosterSettings = {}; for (const name of Object.keys(DEFAULTS.hosterSettings)) { @@ -196,3 +252,6 @@ class ConfigStore { } module.exports = ConfigStore; +module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES; +module.exports.HOSTER_NAMES = HOSTER_NAMES; +module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 8bde882..c76267d 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -37,6 +37,12 @@ class UploadManager extends EventEmitter { this.lastStartTime = {}; // hoster -> timestamp of last upload start this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits this.globalThrottle = null; + this._failedAccounts = new Map(); // hoster -> Set of failed accountIds + this._accountOverrides = new Map(); // hoster -> fallback account object + } + + switchAccount(hoster, fallbackAccount) { + this._accountOverrides.set(hoster, fallbackAccount); } updateSettings(hosterSettings, globalSettings) { @@ -364,22 +370,7 @@ class UploadManager extends EventEmitter { }); }; - 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, uploadSignalBundle.signal, throttle); - } else if (task.hoster === 'voe.sx' && task.username) { - const voe = new VoeUploader(); - await voe.login(task.username, task.password); - result = await voe.upload(task.file, progressCb, uploadSignalBundle.signal, throttle); - } else if (task.hoster === 'doodstream.com' && task.username) { - const dood = new DoodstreamUploader(); - await dood.login(task.username, task.password); - result = await dood.upload(task.file, progressCb, uploadSignalBundle.signal, throttle); - } else { - result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, uploadSignalBundle.signal, throttle); - } + const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle); const elapsed = Math.round((Date.now() - jobStart) / 1000); this.sessionBytes += fileSize; @@ -432,6 +423,83 @@ class UploadManager extends EventEmitter { return; } + // Account fallback: if this account hasn't failed before, try switching + if (task.accountId && !this._failedAccounts.has(task.hoster + ':' + task.accountId)) { + this._failedAccounts.set(task.hoster + ':' + task.accountId, true); + this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); + // Wait briefly for switchAccount() to be called from main process + await this._sleep(800, signal); + const override = this._accountOverrides.get(task.hoster); + if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { + // Switch to fallback account and retry this file + task.accountId = override.id; + task.username = override.username; + task.password = override.password; + task.apiKey = override.apiKey; + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts + }); + // Re-run retry loop with new account + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted || this.stopAfterActive) break; + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: lastError ? lastError.message : '', result: null, attempt, maxAttempts + }); + await this._sleep(3000, signal); + } + try { + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 }); + + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded }); + const elapsed = Math.round((now - jobStart) / 1000); + const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; + this._emitProgress(uploadId, fileName, task.hoster, { + jobId, status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, + elapsed, remaining, error: null, result: null, attempt, maxAttempts + }); + }; + + const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null; + const globalThrottle = this._getGlobalThrottle(); + const throttle = hosterThrottle && globalThrottle + ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } + : hosterThrottle || globalThrottle; + + const result = await this._executeUpload(task, progressCb, signal, throttle); + this.activeJobs.delete(uploadId); + this.sessionBytes += fileSize; + emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); + recordFinalResult('done', { result }); + return; + } catch (err) { + this.activeJobs.delete(uploadId); + lastError = err; + if (signal.aborted || this.stopAfterActive) break; + if (attempt >= maxAttempts) break; + } + } + } + } + const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; emitFinalStatus('error', { error }); recordFinalResult('error', { error }); @@ -453,6 +521,24 @@ class UploadManager extends EventEmitter { } } + async _executeUpload(task, progressCb, signal, throttle) { + if (task.hoster === 'vidmoly.me' && task.username) { + const vidmoly = new VidmolyUploader(); + await vidmoly.login(task.username, task.password); + return vidmoly.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'voe.sx' && task.username) { + const voe = new VoeUploader(); + await voe.login(task.username, task.password); + return voe.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'doodstream.com' && task.username) { + const dood = new DoodstreamUploader(); + await dood.login(task.username, task.password); + return dood.upload(task.file, progressCb, signal, throttle); + } else { + return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle); + } + } + _emitProgress(uploadId, fileName, hoster, data) { this.emit('progress', { uploadId, fileName, hoster, ...data }); } diff --git a/main.js b/main.js index ce6151c..d996d29 100644 --- a/main.js +++ b/main.js @@ -115,82 +115,68 @@ function appendUploadLog(hoster, link, fileName) { } catch {} } -function buildUploadTasks(config, files, hosters) { - const tasks = []; +// --- Multi-account helpers --- +function hosterAccountHasCreds(name, account) { + if (!account) return false; + if (account.authType === 'api') return !!account.apiKey; + if (account.authType === 'login') return !!(account.username && account.password); + // Fallback for old format + if (name === 'vidmoly.me') return !!(account.username && account.password); + if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; + return !!account.apiKey; +} - for (const file of files) { - for (const hoster of hosters) { - const hosterConfig = config.hosters[hoster]; - if (!hosterConfig) { - debugLog(` skip ${hoster}: no config`); - continue; - } +function getPrimaryAccount(config, hosterName) { + const accounts = config.hosters[hosterName]; + if (!Array.isArray(accounts)) return null; + return accounts.find(a => a.enabled !== false && hosterAccountHasCreds(hosterName, a)) || null; +} - if (hoster === 'vidmoly.me') { - if (!hosterConfig.username || !hosterConfig.password) { - debugLog(` skip ${hoster}: missing username/password`); - continue; - } - tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); - } else if (hoster === 'voe.sx' && hosterConfig.username && hosterConfig.password) { - // VOE login-based upload (preferred over API) - tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); - debugLog(` task: ${hoster} login=${hosterConfig.username.slice(0, 6)}...`); - } else if (hoster === 'doodstream.com' && hosterConfig.username && hosterConfig.password) { - // Doodstream login-based upload (preferred over API) - tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); - debugLog(` task: ${hoster} login=${hosterConfig.username.slice(0, 6)}...`); - } else { - if (!hosterConfig.apiKey) { - debugLog(` skip ${hoster}: missing apiKey`); - continue; - } - tasks.push({ file, hoster, apiKey: hosterConfig.apiKey }); - debugLog(` task: ${hoster} key=${hosterConfig.apiKey.slice(0, 6)}...`); - } +function getNextFallbackAccount(config, hosterName, failedAccountId) { + const accounts = config.hosters[hosterName]; + if (!Array.isArray(accounts)) return null; + const failedIndex = accounts.findIndex(a => a.id === failedAccountId); + if (failedIndex < 0) return null; + for (let i = failedIndex + 1; i < accounts.length; i++) { + if (accounts[i].enabled !== false && hosterAccountHasCreds(hosterName, accounts[i])) { + return accounts[i]; } } + return null; +} +function buildTaskFromAccount(hoster, account, extra) { + const task = { ...extra, hoster, accountId: account.id }; + if (account.authType === 'api' && account.apiKey) { + task.apiKey = account.apiKey; + } else if (account.username && account.password) { + task.username = account.username; + task.password = account.password; + } else if (account.apiKey) { + task.apiKey = account.apiKey; + } + return task; +} + +function buildUploadTasks(config, files, hosters) { + const tasks = []; + for (const file of files) { + for (const hoster of hosters) { + const account = getPrimaryAccount(config, hoster); + if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; } + tasks.push(buildTaskFromAccount(hoster, account, { file })); + } + } 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 }]; + const account = getPrimaryAccount(config, job.hoster); + if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); return []; } + return [buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })]; }); } @@ -360,6 +346,13 @@ async function checkByseHealth(hosterConfig) { } const msg = String(serverPayload.msg || serverPayload.message || '').trim(); + + // Byse API returns { msg: "OK", result: } on success. + // If msg is "OK" but result wasn't a valid URL, treat as success with warning. + if (/^ok$/i.test(msg)) { + return { status: 'ok', message: 'API Key gültig' }; + } + if (msg) { return { status: 'error', message: msg }; } @@ -367,75 +360,69 @@ async function checkByseHealth(hosterConfig) { return { status: 'error', message: 'API Key ungültig oder Server nicht erreichbar' }; } -async function runHosterHealthCheck(config, requestedHosters) { +// requestedChecks can be: +// - array of strings (hoster names) for legacy/all-accounts check +// - array of { hoster, accountId } for specific account checks +async function runHosterHealthCheck(config, requestedChecks) { const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx']; - const source = Array.isArray(requestedHosters) && requestedHosters.length > 0 - ? requestedHosters - : allowed; - const hosters = source - .map((name) => String(name || '').trim()) - .filter((name, index, arr) => name && arr.indexOf(name) === index); + // Normalize input to [{ hoster, accountId? }] + let checks; + if (!Array.isArray(requestedChecks) || requestedChecks.length === 0) { + // Check all accounts for all hosters + checks = []; + for (const name of allowed) { + const accounts = config.hosters[name]; + if (Array.isArray(accounts)) { + for (const acc of accounts) { + if (hosterAccountHasCreds(name, acc)) checks.push({ hoster: name, accountId: acc.id }); + } + } + } + } else if (typeof requestedChecks[0] === 'string') { + // Legacy: array of hoster names — check all accounts for each + checks = []; + for (const name of requestedChecks) { + const accounts = config.hosters[name]; + if (Array.isArray(accounts)) { + for (const acc of accounts) { + if (hosterAccountHasCreds(name, acc)) checks.push({ hoster: name, accountId: acc.id }); + } + } + } + } else { + checks = requestedChecks; + } - const checks = hosters.map(async (hoster) => { + const results = await Promise.all(checks.map(async ({ hoster, accountId }) => { if (!allowed.includes(hoster)) { - return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; + return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } - const hosterConfig = config && config.hosters ? config.hosters[hoster] : null; + // Find specific account + const accounts = config.hosters[hoster]; + const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; try { + let result; if (hoster === 'doodstream.com') { - const result = await withTimeout( - checkDoodstreamHealth(hosterConfig), - HEALTH_CHECK_TIMEOUT, - 'Doodstream-Check' - ); - return { hoster, ...result }; + result = await withTimeout(checkDoodstreamHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check'); + } else if (hoster === 'vidmoly.me') { + result = await withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check'); + } else if (hoster === 'voe.sx') { + result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check'); + } else if (hoster === 'byse.sx') { + result = await withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check'); + } else { + return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } - - if (hoster === 'vidmoly.me') { - const result = await withTimeout( - checkVidmolyHealth(hosterConfig), - HEALTH_CHECK_TIMEOUT, - 'Vidmoly-Check' - ); - return { hoster, ...result }; - } - - if (hoster === 'voe.sx') { - const result = await withTimeout( - checkVoeHealth(hosterConfig), - HEALTH_CHECK_TIMEOUT, - 'VOE-Check' - ); - return { hoster, ...result }; - } - - if (hoster === 'byse.sx') { - const result = await withTimeout( - checkByseHealth(hosterConfig), - HEALTH_CHECK_TIMEOUT, - 'Byse-Check' - ); - return { hoster, ...result }; - } - - return { hoster, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; + return { hoster, accountId, ...result }; } catch (err) { - return { - hoster, - status: 'error', - message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' - }; + return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' }; } - }); + })); - const results = await Promise.all(checks); - return { - checkedAt: new Date().toISOString(), - results - }; + return { checkedAt: new Date().toISOString(), results }; } function createWindow() { @@ -680,6 +667,20 @@ ipcMain.handle('start-upload', (_event, payload) => { } }); + uploadManager.on('account-failed', ({ hoster, accountId }) => { + const cfg = configStore.load(); + const fallback = getNextFallbackAccount(cfg, hoster, accountId); + if (fallback) { + debugLog(`account-failed: ${hoster} ${accountId} → fallback to ${fallback.id}`); + uploadManager.switchAccount(hoster, fallback); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); + } + } else { + debugLog(`account-failed: ${hoster} ${accountId} → no fallback available`); + } + }); + uploadManager.on('batch-done', async (summary) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); await configStore.appendHistory(summary); diff --git a/package.json b/package.json index bf5798d..123890c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "1.9.8", + "version": "2.0.0", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index 5c827fd..e29c761 100644 --- a/preload.js +++ b/preload.js @@ -64,6 +64,11 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); }, + // Account switched event + onAccountSwitched: (callback) => { + ipcRenderer.on('account-switched', (_event, data) => callback(data)); + }, + // Drop Target showDropTarget: () => ipcRenderer.invoke('show-drop-target'), hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'), @@ -99,5 +104,6 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('folder-monitor:new-files'); ipcRenderer.removeAllListeners('drop-target:files'); + ipcRenderer.removeAllListeners('account-switched'); } }); diff --git a/renderer/app.js b/renderer/app.js index 42ea938..8d5871e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1,5 +1,15 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; +// Dropdown options for "Add Account" modal: value -> label +const HOSTER_ADD_OPTIONS = [ + { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, + { value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' }, + { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, + { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, + { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, + { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' } +]; + // --- State --- let selectedFiles = []; // { path, name, size } let selectedUploadHosters = []; @@ -7,8 +17,8 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; -let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } -let editingAccountHoster = null; // null = adding, string = editing +let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } +let editingAccountId = null; // null = adding, string = editing account by ID let autoHealthCheckEnabled = true; let queuePersistTimer = null; let settingsSaveTimer = null; @@ -108,6 +118,11 @@ async function init() { } }); + // Account switched notification + window.api.onAccountSwitched((data) => { + window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`); + }); + // Drop target window: files dropped on the small floating window window.api.onDropTargetFiles((paths) => { addPathsToQueue(paths); @@ -136,19 +151,26 @@ document.querySelectorAll('.tab').forEach(tab => { }); // --- Hoster selection --- -function hosterHasCredentials(name, hoster) { - if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); - if (name === 'voe.sx' || name === 'doodstream.com') return !!(hoster.username && hoster.password) || !!hoster.apiKey; - return !!hoster.apiKey; +function accountHasCreds(name, account) { + if (!account) return false; + if (account.authType === 'api') return !!account.apiKey; + if (account.authType === 'login') return !!(account.username && account.password); + // Fallback + if (name === 'vidmoly.me') return !!(account.username && account.password); + if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; + return !!account.apiKey; } +// Returns hosters that have at least one enabled account with credentials function getAvailableHosters() { - return HOSTERS - .map(name => { - const hoster = config.hosters[name] || {}; - return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) }; - }) - .filter(item => item.hasCreds && item.hoster.enabled !== false); + const result = []; + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); + if (hasEnabledAccount) result.push({ name }); + } + return result; } function syncSelectedUploadHosters() { @@ -156,8 +178,8 @@ function syncSelectedUploadHosters() { selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); if (selectedUploadHosters.length === 0) { selectedUploadHosters = HOSTERS.filter(name => { - const hoster = config.hosters[name] || {}; - return !!hoster.enabled && hosterHasCredentials(name, hoster); + const accounts = config.hosters[name]; + return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); }); } } @@ -176,24 +198,17 @@ function getHosterLabel(name) { return labels[name] || name; } -function getAccountModeParts(name, hoster) { - if (!hoster) return []; - const hasLogin = !!(hoster.username && hoster.password); - const hasApi = !!hoster.apiKey; - - if (name === 'vidmoly.me') return hasLogin ? ['Login Web'] : []; - if (name === 'byse.sx') return hasApi ? ['API'] : []; - - const parts = []; - if (hasLogin) parts.push('Login Web'); - if (hasApi) parts.push('API'); - return parts; +function getAccountAuthLabel(account) { + if (!account) return ''; + if (account.authType === 'api') return 'API'; + if (account.authType === 'login') return 'Web Login'; + return ''; } -function getAccountDisplayName(name, hoster) { - const parts = getAccountModeParts(name, hoster); - return parts.length > 0 - ? `${getHosterLabel(name)} (${parts.join(' + ')})` +function getAccountDisplayName(name, account) { + const authLabel = getAccountAuthLabel(account); + return authLabel + ? `${getHosterLabel(name)} (${authLabel})` : getHosterLabel(name); } @@ -206,14 +221,43 @@ function maskCredential(value, keep = 4) { function ensureAccountStatusEntries() { const nextStatuses = {}; - for (const { name } of getAccountsWithCreds()) { - nextStatuses[name] = accountStatuses[name] || { status: 'unchecked', message: '' }; + for (const { account } of getAllAccountsFlat()) { + if (account.id) { + nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; + } } accountStatuses = nextStatuses; } +// Returns flat array of all accounts: [{ name, account, index }] +function getAllAccountsFlat() { + const result = []; + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + accounts.forEach((account, index) => result.push({ name, account, index })); + } + return result; +} + +// Returns flat array of accounts with credentials +function getAccountsWithCredsFlat() { + return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account)); +} + +// Find account by ID across all hosters +function findAccountById(accountId) { + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + const account = accounts.find(a => a.id === accountId); + if (account) return { name, account }; + } + return null; +} + function scheduleStartupAccountCheck() { - const accounts = getAccountsWithCreds(); + const accounts = getAccountsWithCredsFlat(); if (!accounts.length) return; setTimeout(() => { runHealthCheck('startup').catch(() => {}); @@ -227,7 +271,7 @@ function renderHosterSummary() { if (hosters.length === 0) { summary.textContent = 'Keine Upload-Ziele ausgewählt'; } else if (hosters.length === 1) { - summary.textContent = `Aktives Ziel: ${getAccountDisplayName(hosters[0], config.hosters[hosters[0]] || {})}`; + summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`; } else { summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; } @@ -247,17 +291,21 @@ function renderHosterModal() { list.innerHTML = available.map(item => { const checked = selectedUploadHosters.includes(item.name); - const h = config.hosters[item.name] || {}; - const st = accountStatuses[item.name]; - const subtitle = st && st.status === 'ok' ? 'Bereit' - : st && st.status === 'warn' ? 'Prüfung mit Warnung' - : st && st.status === 'error' ? 'Login-Fehler' - : `${getCredentialLabel(item.name, h)} hinterlegt`; + // Get first enabled account's status for subtitle + const accounts = config.hosters[item.name] || []; + const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a)); + const accountCount = enabledAccounts.length; + let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`; + // Check if any account has ok status + const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok'); + const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error'); + if (hasOk) subtitle += ' • Bereit'; + else if (hasError) subtitle += ' • Fehler'; return ` @@ -1459,7 +1507,7 @@ async function retrySelectedJobs() { const retryJobs = []; queueJobs.forEach(j => { if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { - j.status = 'preview'; + j.status = uploading ? 'queued' : 'preview'; j.error = null; j.result = null; j.bytesUploaded = 0; @@ -1720,11 +1768,14 @@ async function executeHealthCheck(hosters, mode) { const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; rows.forEach((row) => { - if (!row || !row.hoster) return; - accountStatuses[row.hoster] = { - status: row.status || 'unchecked', - message: row.message || '' - }; + if (!row) return; + const key = row.accountId || row.hoster; + if (key) { + accountStatuses[key] = { + status: row.status || 'unchecked', + message: row.message || '' + }; + } }); renderHealthCheckResults(rows); renderAccounts(); @@ -1734,17 +1785,25 @@ async function executeHealthCheck(hosters, mode) { async function runHealthCheck(mode = 'manual', requestedHosters = null) { if (healthCheckRunning || (uploading && mode === 'manual')) return []; - const hosters = Array.isArray(requestedHosters) && requestedHosters.length > 0 - ? requestedHosters - : HOSTERS.filter((name) => hosterHasCredentials(name, config.hosters[name] || {})); + // Build check list: all enabled accounts with creds + let hosters; + if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { + hosters = requestedHosters; + } else { + hosters = getAccountsWithCredsFlat() + .filter(({ account }) => account.enabled !== false) + .map(({ name, account }) => ({ hoster: name, accountId: account.id })); + } if (hosters.length === 0) { if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); return []; } healthCheckRunning = true; - hosters.forEach((hoster) => { - accountStatuses[hoster] = { status: 'checking', message: '' }; - }); + // Mark all accounts as checking + for (const h of hosters) { + const key = typeof h === 'string' ? h : (h.accountId || h.hoster); + accountStatuses[key] = { status: 'checking', message: '' }; + } renderAccounts(); try { return await executeHealthCheck(hosters, mode); @@ -1763,7 +1822,7 @@ function renderSettings() { container.innerHTML = ''; const globalSettings = config.globalSettings || {}; - const configuredAccounts = getAccountsWithCreds(); + const configuredAccounts = getAvailableHosters(); const generalPanel = document.createElement('div'); generalPanel.className = 'hoster-settings-panel'; generalPanel.innerHTML = ` @@ -1969,7 +2028,7 @@ function renderSettings() { container.appendChild(empty); } - for (const { name, hoster } of configuredAccounts) { + for (const { name } of configuredAccounts) { const hs = hosterSettings[name] || {}; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; @@ -1979,7 +2038,7 @@ function renderSettings() { panel.innerHTML = `
- ${escapeHtml(getAccountDisplayName(name, hoster))} + ${escapeHtml(getHosterLabel(name))} Aktiv