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 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-12 05:00:33 +01:00
parent 2c9726a33d
commit d53eea443e
7 changed files with 665 additions and 343 deletions

View File

@ -10,12 +10,35 @@ const HOSTER_SETTINGS_DEFAULTS = {
maxSizeMb: 0 // 0 = unlimited 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 = { const DEFAULTS = {
hosters: { hosters: {
'doodstream.com': { enabled: true, apiKey: '', username: '', password: '' }, 'doodstream.com': [],
'voe.sx': { enabled: true, apiKey: '' }, 'voe.sx': [],
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, 'vidmoly.me': [],
'byse.sx': { enabled: true, apiKey: '' } 'byse.sx': []
}, },
hosterSettings: { hosterSettings: {
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS }, 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
@ -113,13 +136,46 @@ class ConfigStore {
} }
if (!data) return JSON.parse(JSON.stringify(DEFAULTS)); if (!data) return JSON.parse(JSON.stringify(DEFAULTS));
// Merge with defaults so new hosters are always present // Migrate old single-object format to array format
const hosters = { ...DEFAULTS.hosters };
for (const [name, val] of Object.entries(data.hosters || {})) { for (const [name, val] of Object.entries(data.hosters || {})) {
if (hosters[name]) { if (val && !Array.isArray(val)) {
hosters[name] = { ...hosters[name], ...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 // Merge hoster settings with defaults
const hosterSettings = {}; const hosterSettings = {};
for (const name of Object.keys(DEFAULTS.hosterSettings)) { for (const name of Object.keys(DEFAULTS.hosterSettings)) {
@ -196,3 +252,6 @@ class ConfigStore {
} }
module.exports = 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;

View File

@ -37,6 +37,12 @@ class UploadManager extends EventEmitter {
this.lastStartTime = {}; // hoster -> timestamp of last upload start this.lastStartTime = {}; // hoster -> timestamp of last upload start
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
this.globalThrottle = null; 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) { updateSettings(hosterSettings, globalSettings) {
@ -364,22 +370,7 @@ class UploadManager extends EventEmitter {
}); });
}; };
let result; const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
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 elapsed = Math.round((Date.now() - jobStart) / 1000); const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
@ -432,6 +423,83 @@ class UploadManager extends EventEmitter {
return; 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'; const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
emitFinalStatus('error', { error }); emitFinalStatus('error', { error });
recordFinalResult('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) { _emitProgress(uploadId, fileName, hoster, data) {
this.emit('progress', { uploadId, fileName, hoster, ...data }); this.emit('progress', { uploadId, fileName, hoster, ...data });
} }

241
main.js
View File

@ -115,82 +115,68 @@ function appendUploadLog(hoster, link, fileName) {
} catch {} } catch {}
} }
function buildUploadTasks(config, files, hosters) { // --- Multi-account helpers ---
const tasks = []; 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) { function getPrimaryAccount(config, hosterName) {
for (const hoster of hosters) { const accounts = config.hosters[hosterName];
const hosterConfig = config.hosters[hoster]; if (!Array.isArray(accounts)) return null;
if (!hosterConfig) { return accounts.find(a => a.enabled !== false && hosterAccountHasCreds(hosterName, a)) || null;
debugLog(` skip ${hoster}: no config`); }
continue;
}
if (hoster === 'vidmoly.me') { function getNextFallbackAccount(config, hosterName, failedAccountId) {
if (!hosterConfig.username || !hosterConfig.password) { const accounts = config.hosters[hosterName];
debugLog(` skip ${hoster}: missing username/password`); if (!Array.isArray(accounts)) return null;
continue; const failedIndex = accounts.findIndex(a => a.id === failedAccountId);
} if (failedIndex < 0) return null;
tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); for (let i = failedIndex + 1; i < accounts.length; i++) {
} else if (hoster === 'voe.sx' && hosterConfig.username && hosterConfig.password) { if (accounts[i].enabled !== false && hosterAccountHasCreds(hosterName, accounts[i])) {
// VOE login-based upload (preferred over API) return accounts[i];
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)}...`);
}
} }
} }
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; return tasks;
} }
function buildUploadTasksFromJobs(config, jobs) { function buildUploadTasksFromJobs(config, jobs) {
if (!Array.isArray(jobs)) return []; if (!Array.isArray(jobs)) return [];
return jobs.flatMap((job) => { return jobs.flatMap((job) => {
if (!job || !job.file || !job.hoster) return []; if (!job || !job.file || !job.hoster) return [];
const hosterConfig = config.hosters[job.hoster]; const account = getPrimaryAccount(config, job.hoster);
if (!hosterConfig) { if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); return []; }
debugLog(` skip ${job.hoster}: no config for queued job`); return [buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })];
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 }];
}); });
} }
@ -360,6 +346,13 @@ async function checkByseHealth(hosterConfig) {
} }
const msg = String(serverPayload.msg || serverPayload.message || '').trim(); const msg = String(serverPayload.msg || serverPayload.message || '').trim();
// Byse API returns { msg: "OK", result: <server-url> } 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) { if (msg) {
return { status: 'error', message: 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' }; 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 allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx'];
const source = Array.isArray(requestedHosters) && requestedHosters.length > 0
? requestedHosters
: allowed;
const hosters = source // Normalize input to [{ hoster, accountId? }]
.map((name) => String(name || '').trim()) let checks;
.filter((name, index, arr) => name && arr.indexOf(name) === index); 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)) { 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 { try {
let result;
if (hoster === 'doodstream.com') { if (hoster === 'doodstream.com') {
const result = await withTimeout( result = await withTimeout(checkDoodstreamHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
checkDoodstreamHealth(hosterConfig), } else if (hoster === 'vidmoly.me') {
HEALTH_CHECK_TIMEOUT, result = await withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
'Doodstream-Check' } else if (hoster === 'voe.sx') {
); result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check');
return { hoster, ...result }; } 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' };
} }
return { hoster, accountId, ...result };
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' };
} catch (err) { } catch (err) {
return { return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' };
hoster,
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() { 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) => { uploadManager.on('batch-done', async (summary) => {
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
await configStore.appendHistory(summary); await configStore.appendHistory(summary);

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "1.9.8", "version": "2.0.0",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -64,6 +64,11 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); 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 // Drop Target
showDropTarget: () => ipcRenderer.invoke('show-drop-target'), showDropTarget: () => ipcRenderer.invoke('show-drop-target'),
hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'), hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'),
@ -99,5 +104,6 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('shutdown-countdown');
ipcRenderer.removeAllListeners('folder-monitor:new-files'); ipcRenderer.removeAllListeners('folder-monitor:new-files');
ipcRenderer.removeAllListeners('drop-target:files'); ipcRenderer.removeAllListeners('drop-target:files');
ipcRenderer.removeAllListeners('account-switched');
} }
}); });

View File

@ -1,5 +1,15 @@
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; 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 --- // --- State ---
let selectedFiles = []; // { path, name, size } let selectedFiles = []; // { path, name, size }
let selectedUploadHosters = []; let selectedUploadHosters = [];
@ -7,8 +17,8 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {}; let hosterSettings = {};
let uploading = false; let uploading = false;
let healthCheckRunning = false; let healthCheckRunning = false;
let accountStatuses = {}; // { 'voe.sx': { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountHoster = null; // null = adding, string = editing let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true; let autoHealthCheckEnabled = true;
let queuePersistTimer = null; let queuePersistTimer = null;
let settingsSaveTimer = 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 // Drop target window: files dropped on the small floating window
window.api.onDropTargetFiles((paths) => { window.api.onDropTargetFiles((paths) => {
addPathsToQueue(paths); addPathsToQueue(paths);
@ -136,19 +151,26 @@ document.querySelectorAll('.tab').forEach(tab => {
}); });
// --- Hoster selection --- // --- Hoster selection ---
function hosterHasCredentials(name, hoster) { function accountHasCreds(name, account) {
if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); if (!account) return false;
if (name === 'voe.sx' || name === 'doodstream.com') return !!(hoster.username && hoster.password) || !!hoster.apiKey; if (account.authType === 'api') return !!account.apiKey;
return !!hoster.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() { function getAvailableHosters() {
return HOSTERS const result = [];
.map(name => { for (const name of HOSTERS) {
const hoster = config.hosters[name] || {}; const accounts = config.hosters[name];
return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) }; if (!Array.isArray(accounts)) continue;
}) const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
.filter(item => item.hasCreds && item.hoster.enabled !== false); if (hasEnabledAccount) result.push({ name });
}
return result;
} }
function syncSelectedUploadHosters() { function syncSelectedUploadHosters() {
@ -156,8 +178,8 @@ function syncSelectedUploadHosters() {
selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name));
if (selectedUploadHosters.length === 0) { if (selectedUploadHosters.length === 0) {
selectedUploadHosters = HOSTERS.filter(name => { selectedUploadHosters = HOSTERS.filter(name => {
const hoster = config.hosters[name] || {}; const accounts = config.hosters[name];
return !!hoster.enabled && hosterHasCredentials(name, hoster); return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
}); });
} }
} }
@ -176,24 +198,17 @@ function getHosterLabel(name) {
return labels[name] || name; return labels[name] || name;
} }
function getAccountModeParts(name, hoster) { function getAccountAuthLabel(account) {
if (!hoster) return []; if (!account) return '';
const hasLogin = !!(hoster.username && hoster.password); if (account.authType === 'api') return 'API';
const hasApi = !!hoster.apiKey; if (account.authType === 'login') return 'Web Login';
return '';
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 getAccountDisplayName(name, hoster) { function getAccountDisplayName(name, account) {
const parts = getAccountModeParts(name, hoster); const authLabel = getAccountAuthLabel(account);
return parts.length > 0 return authLabel
? `${getHosterLabel(name)} (${parts.join(' + ')})` ? `${getHosterLabel(name)} (${authLabel})`
: getHosterLabel(name); : getHosterLabel(name);
} }
@ -206,14 +221,43 @@ function maskCredential(value, keep = 4) {
function ensureAccountStatusEntries() { function ensureAccountStatusEntries() {
const nextStatuses = {}; const nextStatuses = {};
for (const { name } of getAccountsWithCreds()) { for (const { account } of getAllAccountsFlat()) {
nextStatuses[name] = accountStatuses[name] || { status: 'unchecked', message: '' }; if (account.id) {
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
}
} }
accountStatuses = nextStatuses; 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() { function scheduleStartupAccountCheck() {
const accounts = getAccountsWithCreds(); const accounts = getAccountsWithCredsFlat();
if (!accounts.length) return; if (!accounts.length) return;
setTimeout(() => { setTimeout(() => {
runHealthCheck('startup').catch(() => {}); runHealthCheck('startup').catch(() => {});
@ -227,7 +271,7 @@ function renderHosterSummary() {
if (hosters.length === 0) { if (hosters.length === 0) {
summary.textContent = 'Keine Upload-Ziele ausgewählt'; summary.textContent = 'Keine Upload-Ziele ausgewählt';
} else if (hosters.length === 1) { } else if (hosters.length === 1) {
summary.textContent = `Aktives Ziel: ${getAccountDisplayName(hosters[0], config.hosters[hosters[0]] || {})}`; summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`;
} else { } else {
summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`;
} }
@ -247,17 +291,21 @@ function renderHosterModal() {
list.innerHTML = available.map(item => { list.innerHTML = available.map(item => {
const checked = selectedUploadHosters.includes(item.name); const checked = selectedUploadHosters.includes(item.name);
const h = config.hosters[item.name] || {}; // Get first enabled account's status for subtitle
const st = accountStatuses[item.name]; const accounts = config.hosters[item.name] || [];
const subtitle = st && st.status === 'ok' ? 'Bereit' const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a));
: st && st.status === 'warn' ? 'Prüfung mit Warnung' const accountCount = enabledAccounts.length;
: st && st.status === 'error' ? 'Login-Fehler' let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`;
: `${getCredentialLabel(item.name, h)} hinterlegt`; // 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 ` return `
<label class="hoster-option${checked ? ' selected' : ''}" data-hoster-option="${item.name}"> <label class="hoster-option${checked ? ' selected' : ''}" data-hoster-option="${item.name}">
<input type="checkbox" data-hoster-modal="${item.name}" ${checked ? 'checked' : ''}> <input type="checkbox" data-hoster-modal="${item.name}" ${checked ? 'checked' : ''}>
<div class="hoster-option-main"> <div class="hoster-option-main">
<div class="hoster-option-title">${escapeHtml(getAccountDisplayName(item.name, h))}</div> <div class="hoster-option-title">${escapeHtml(getHosterLabel(item.name))}</div>
<div class="hoster-option-subtitle">${subtitle}</div> <div class="hoster-option-subtitle">${subtitle}</div>
</div> </div>
</label> </label>
@ -1459,7 +1507,7 @@ async function retrySelectedJobs() {
const retryJobs = []; const retryJobs = [];
queueJobs.forEach(j => { queueJobs.forEach(j => {
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
j.status = 'preview'; j.status = uploading ? 'queued' : 'preview';
j.error = null; j.error = null;
j.result = null; j.result = null;
j.bytesUploaded = 0; j.bytesUploaded = 0;
@ -1720,11 +1768,14 @@ async function executeHealthCheck(hosters, mode) {
const result = await window.api.runHealthCheck({ hosters }); const result = await window.api.runHealthCheck({ hosters });
const rows = result && Array.isArray(result.results) ? result.results : []; const rows = result && Array.isArray(result.results) ? result.results : [];
rows.forEach((row) => { rows.forEach((row) => {
if (!row || !row.hoster) return; if (!row) return;
accountStatuses[row.hoster] = { const key = row.accountId || row.hoster;
status: row.status || 'unchecked', if (key) {
message: row.message || '' accountStatuses[key] = {
}; status: row.status || 'unchecked',
message: row.message || ''
};
}
}); });
renderHealthCheckResults(rows); renderHealthCheckResults(rows);
renderAccounts(); renderAccounts();
@ -1734,17 +1785,25 @@ async function executeHealthCheck(hosters, mode) {
async function runHealthCheck(mode = 'manual', requestedHosters = null) { async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning || (uploading && mode === 'manual')) return []; if (healthCheckRunning || (uploading && mode === 'manual')) return [];
const hosters = Array.isArray(requestedHosters) && requestedHosters.length > 0 // Build check list: all enabled accounts with creds
? requestedHosters let hosters;
: HOSTERS.filter((name) => hosterHasCredentials(name, config.hosters[name] || {})); 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 (hosters.length === 0) {
if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.');
return []; return [];
} }
healthCheckRunning = true; healthCheckRunning = true;
hosters.forEach((hoster) => { // Mark all accounts as checking
accountStatuses[hoster] = { status: 'checking', message: '' }; for (const h of hosters) {
}); const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
accountStatuses[key] = { status: 'checking', message: '' };
}
renderAccounts(); renderAccounts();
try { try {
return await executeHealthCheck(hosters, mode); return await executeHealthCheck(hosters, mode);
@ -1763,7 +1822,7 @@ function renderSettings() {
container.innerHTML = ''; container.innerHTML = '';
const globalSettings = config.globalSettings || {}; const globalSettings = config.globalSettings || {};
const configuredAccounts = getAccountsWithCreds(); const configuredAccounts = getAvailableHosters();
const generalPanel = document.createElement('div'); const generalPanel = document.createElement('div');
generalPanel.className = 'hoster-settings-panel'; generalPanel.className = 'hoster-settings-panel';
generalPanel.innerHTML = ` generalPanel.innerHTML = `
@ -1969,7 +2028,7 @@ function renderSettings() {
container.appendChild(empty); container.appendChild(empty);
} }
for (const { name, hoster } of configuredAccounts) { for (const { name } of configuredAccounts) {
const hs = hosterSettings[name] || {}; const hs = hosterSettings[name] || {};
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0';
@ -1979,7 +2038,7 @@ function renderSettings() {
panel.innerHTML = ` panel.innerHTML = `
<div class="hoster-panel-header" data-hoster="${name}"> <div class="hoster-panel-header" data-hoster="${name}">
<span class="panel-arrow">&#9654;</span> <span class="panel-arrow">&#9654;</span>
<span class="panel-title">${escapeHtml(getAccountDisplayName(name, hoster))}</span> <span class="panel-title">${escapeHtml(getHosterLabel(name))}</span>
<span class="panel-status active">Aktiv</span> <span class="panel-status active">Aktiv</span>
</div> </div>
<div class="hoster-panel-body" data-panel="${name}" style="display:none"> <div class="hoster-panel-body" data-panel="${name}" style="display:none">
@ -2155,25 +2214,14 @@ async function saveSettings(options = {}) {
} }
// --- Accounts --- // --- Accounts ---
function getAccountsWithCreds() { function getCredentialLabel(name, account) {
return HOSTERS if (!account) return 'Keine Zugangsdaten';
.map(name => ({ name, hoster: config.hosters[name] || {} })) if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`;
.filter(item => hosterHasCredentials(item.name, item.hoster)); if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`;
} // Fallback
if (account.username && account.password) return `Login: ${account.username}`;
function getHostersWithoutCreds() { if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`;
return HOSTERS.filter(name => !hosterHasCredentials(name, config.hosters[name] || {})); return 'Keine Zugangsdaten';
}
function getCredentialLabel(name, hoster) {
if (name === 'vidmoly.me') return `Login: ${hoster.username || 'nicht gesetzt'}`;
if (name === 'voe.sx' || name === 'doodstream.com') {
const parts = [];
if (hoster.username && hoster.password) parts.push(`Login: ${hoster.username}`);
if (hoster.apiKey) parts.push(`API: ${maskCredential(hoster.apiKey)}`);
return parts.join(' • ') || 'Keine Zugangsdaten';
}
return `API: ${maskCredential(hoster.apiKey) || 'nicht gesetzt'}`;
} }
function renderAccounts() { function renderAccounts() {
@ -2181,11 +2229,11 @@ function renderAccounts() {
if (!container) return; if (!container) return;
ensureAccountStatusEntries(); ensureAccountStatusEntries();
const accounts = getAccountsWithCreds(); const allAccounts = getAllAccountsFlat();
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
if (accounts.length === 0) { if (allAccounts.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="accounts-empty"> <div class="accounts-empty">
<p>Keine Accounts vorhanden</p> <p>Keine Accounts vorhanden</p>
@ -2194,33 +2242,51 @@ function renderAccounts() {
return; return;
} }
container.innerHTML = accounts.map(({ name, hoster }) => { // Group by hoster for drag reorder sections
const isDisabled = hoster.enabled === false; const byHoster = {};
const st = accountStatuses[name] || { status: 'unchecked', message: '' }; for (const { name, account } of allAccounts) {
const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; if (!byHoster[name]) byHoster[name] = [];
const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft'); byHoster[name].push(account);
const statusClass = isDisabled ? 'disabled' : st.status; }
const credLabel = getCredentialLabel(name, hoster);
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
return ` let html = '';
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account="${name}"> for (const name of HOSTERS) {
<div class="account-card-info"> const accounts = byHoster[name];
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, hoster))}</div> if (!accounts || accounts.length === 0) continue;
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div> html += `<div class="account-hoster-group" data-hoster-group="${name}">
</div> <div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
<span class="account-status status-${statusClass}"> accounts.forEach((account, idx) => {
<span class="account-status-dot"></span> const isDisabled = account.enabled === false;
${statusLabel} const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
</span> const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
<div class="account-card-actions"> const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft');
<button class="btn btn-xs btn-secondary" data-account-toggle="${name}">${toggleLabel}</button> const statusClass = isDisabled ? 'disabled' : st.status;
<button class="btn btn-xs btn-secondary" data-account-check="${name}" ${isDisabled ? 'disabled' : ''}>Prüfen</button> const credLabel = getCredentialLabel(name, account);
<button class="btn btn-xs btn-secondary" data-account-edit="${name}">Bearbeiten</button> const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
<button class="btn btn-xs btn-danger" data-account-delete="${name}">Löschen</button> const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
</div>
</div>`; html += `
}).join(''); <div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
${statusLabel}
</span>
<div class="account-card-actions">
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div>
</div>`;
});
html += '</div>';
}
container.innerHTML = html;
// Wire up buttons // Wire up buttons
container.querySelectorAll('[data-account-toggle]').forEach(btn => { container.querySelectorAll('[data-account-toggle]').forEach(btn => {
@ -2235,14 +2301,73 @@ function renderAccounts() {
container.querySelectorAll('[data-account-check]').forEach(btn => { container.querySelectorAll('[data-account-check]').forEach(btn => {
btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck)); btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck));
}); });
// Drag-and-drop reorder within each hoster group
setupAccountDragReorder(container);
} }
async function toggleAccount(hosterName) { function setupAccountDragReorder(container) {
const hosters = { ...config.hosters }; let draggedCard = null;
const hoster = { ...hosters[hosterName] }; container.querySelectorAll('.account-card[draggable]').forEach(card => {
hoster.enabled = hoster.enabled === false ? true : false; card.addEventListener('dragstart', (e) => {
hosters[hosterName] = hoster; draggedCard = card;
await window.api.saveConfig({ hosters }); card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null;
container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
card.classList.toggle('drag-over-above', e.clientY < midY);
card.classList.toggle('drag-over-below', e.clientY >= midY);
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over-above', 'drag-over-below');
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below');
if (!draggedCard || draggedCard === card) return;
const hosterName = card.dataset.accountHoster;
if (draggedCard.dataset.accountHoster !== hosterName) return;
const draggedId = draggedCard.dataset.accountId;
const targetId = card.dataset.accountId;
const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return;
const fromIdx = accounts.findIndex(a => a.id === draggedId);
const toIdx = accounts.findIndex(a => a.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
// Move account in array
const [moved] = accounts.splice(fromIdx, 1);
const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Save and re-render
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
renderAccounts();
});
});
}
async function toggleAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
found.account.enabled = found.account.enabled === false ? true : false;
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig(); config = await window.api.getConfig();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
renderAccounts(); renderAccounts();
@ -2251,66 +2376,51 @@ async function toggleAccount(hosterName) {
renderSettings(); renderSettings();
} }
async function checkSingleAccount(hosterName) { async function checkSingleAccount(accountId) {
if (!hosterName || healthCheckRunning) return; if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found) return;
healthCheckRunning = true; healthCheckRunning = true;
accountStatuses[hosterName] = { status: 'checking', message: '' }; accountStatuses[accountId] = { status: 'checking', message: '' };
renderAccounts(); renderAccounts();
try { try {
const rows = await executeHealthCheck([hosterName], 'manual'); const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
const row = rows.find(r => r.hoster === hosterName); const rows = result && Array.isArray(result.results) ? result.results : [];
if (row) accountStatuses[hosterName] = { status: row.status || 'error', message: row.message || '' }; const row = rows.find(r => r.accountId === accountId);
if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' };
} catch (err) { } catch (err) {
accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
} finally { } finally {
healthCheckRunning = false; healthCheckRunning = false;
} }
renderAccounts(); renderAccounts();
} }
function getCredsFieldsHtml(name, hoster) { function getCredsFieldsHtml(authType, account) {
hoster = hoster || {}; account = account || {};
if (name === 'vidmoly.me') { if (authType === 'login') {
return ` return `
<div class="settings-row"> <div class="settings-row">
<label>Username</label> <label>Username / E-Mail</label>
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(hoster.username || '')}" placeholder="Username"> <input type="text" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="Username oder E-Mail">
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label>Passwort</label> <label>Passwort</label>
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(hoster.password || '')}" placeholder="Passwort"> <input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
if (name === 'voe.sx' || name === 'doodstream.com') { // API key
return `
<div class="settings-row">
<label>E-Mail (Login)</label>
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(hoster.username || '')}" placeholder="E-Mail für Login">
</div>
<div class="settings-row">
<label>Passwort (Login)</label>
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(hoster.password || '')}" placeholder="Passwort für Login">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>
<div class="settings-row">
<label>API Key (optional)</label>
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(hoster.apiKey || '')}" placeholder="API Key (Fallback)">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>
<p class="hint" style="margin:4px 0 0;opacity:0.6">Login wird bevorzugt. API-Key nur als Fallback.</p>`;
}
// Default: API key only
return ` return `
<div class="settings-row"> <div class="settings-row">
<label>API Key</label> <label>API Key</label>
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(hoster.apiKey || '')}" placeholder="API Key"> <input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button> <button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`; </div>`;
} }
function openAccountModal(editHoster) { function openAccountModal(editAccountId) {
editingAccountHoster = editHoster || null; editingAccountId = editAccountId || null;
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle'); const title = document.getElementById('accountModalTitle');
const subtitle = document.getElementById('accountModalSubtitle'); const subtitle = document.getElementById('accountModalSubtitle');
@ -2323,28 +2433,26 @@ function openAccountModal(editHoster) {
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.className = 'account-modal-status'; statusEl.className = 'account-modal-status';
if (editingAccountHoster) { if (editingAccountId) {
// Edit mode // Edit mode
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten'; title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(editingAccountHoster, config.hosters[editingAccountHoster] || {})} bearbeiten.`; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none'; hosterRow.style.display = 'none';
saveBtn.textContent = 'Speichern & prüfen'; saveBtn.textContent = 'Speichern & prüfen';
const hoster = config.hosters[editingAccountHoster] || {}; credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account);
credsContainer.innerHTML = getCredsFieldsHtml(editingAccountHoster, hoster);
} else { } else {
// Add mode // Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen'; title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.';
hosterRow.style.display = 'flex'; hosterRow.style.display = 'flex';
saveBtn.textContent = 'Anlegen & prüfen'; saveBtn.textContent = 'Anlegen & prüfen';
const available = getHostersWithoutCreds(); hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
if (available.length === 0) { `<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
hosterSelect.innerHTML = '<option value="">Alle Hoster bereits eingerichtet</option>'; ).join('');
credsContainer.innerHTML = ''; const firstOpt = HOSTER_ADD_OPTIONS[0];
} else { credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {});
hosterSelect.innerHTML = available.map(name => `<option value="${name}">${getHosterLabel(name)}</option>`).join('');
credsContainer.innerHTML = getCredsFieldsHtml(available[0], {});
}
} }
// Toggle visibility buttons // Toggle visibility buttons
@ -2360,14 +2468,16 @@ function openAccountModal(editHoster) {
function closeAccountModal() { function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
editingAccountHoster = null; editingAccountId = null;
} }
function openDeleteAccountModal(hosterName) { function openDeleteAccountModal(accountId) {
const found = findAccountById(accountId);
if (!found) return;
const modal = document.getElementById('deleteAccountModal'); const modal = document.getElementById('deleteAccountModal');
const msg = document.getElementById('deleteAccountMessage'); const msg = document.getElementById('deleteAccountMessage');
msg.textContent = `Account für "${getHosterLabel(hosterName)}" wirklich löschen? Alle Zugangsdaten werden entfernt.`; msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`;
modal.dataset.hoster = hosterName; modal.dataset.accountId = accountId;
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@ -2375,22 +2485,20 @@ function closeDeleteModal() {
document.getElementById('deleteAccountModal').style.display = 'none'; document.getElementById('deleteAccountModal').style.display = 'none';
} }
async function deleteAccount(hosterName) { async function deleteAccount(accountId) {
const hosters = { ...config.hosters }; const found = findAccountById(accountId);
// Reset credentials to defaults if (!found) return;
if (hosterName === 'vidmoly.me') { // Remove account from the array
hosters[hosterName] = { enabled: false, authType: 'login', username: '', password: '' }; const accounts = config.hosters[found.name];
} else if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { if (Array.isArray(accounts)) {
hosters[hosterName] = { enabled: false, username: '', password: '', apiKey: '' }; config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
} else {
hosters[hosterName] = { enabled: false, apiKey: '' };
} }
delete accountStatuses[hosterName]; delete accountStatuses[accountId];
await window.api.saveConfig({ hosters }); await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig(); config = await window.api.getConfig();
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
if (getAccountsWithCreds().length === 0) renderHealthCheckResults([]); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
renderAccounts(); renderAccounts();
renderHosterSummary(); renderHosterSummary();
renderHosterModal(); renderHosterModal();
@ -2398,27 +2506,39 @@ async function deleteAccount(hosterName) {
closeDeleteModal(); closeDeleteModal();
} }
function readAccountCredsFromModal(hosterName) { function readAccountCredsFromModal(authType) {
if (hosterName === 'vidmoly.me') { if (authType === 'login') {
const username = (document.getElementById('accField_username')?.value || '').trim(); const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim();
return { enabled: !!(username && password), authType: 'login', username, password }; return { enabled: !!(username && password), authType: 'login', username, password };
} }
if (hosterName === 'voe.sx' || hosterName === 'doodstream.com') { // API
const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim();
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!(username && password) || !!apiKey, username, password, apiKey };
}
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!apiKey, apiKey }; return { enabled: !!apiKey, authType: 'api', apiKey };
} }
async function saveAccount() { async function saveAccount() {
const hosterName = editingAccountHoster || document.getElementById('accountHosterSelect')?.value; let hosterName, authType, accountId;
if (!hosterName) return;
const creds = readAccountCredsFromModal(hosterName); if (editingAccountId) {
// Edit existing account
const found = findAccountById(editingAccountId);
if (!found) return;
hosterName = found.name;
authType = found.account.authType || 'login';
accountId = editingAccountId;
} else {
// Add new account
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return;
hosterName = opt.hoster;
authType = opt.authType;
accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
const creds = readAccountCredsFromModal(authType);
if (!creds.enabled) { if (!creds.enabled) {
const statusEl = document.getElementById('accountModalStatus'); const statusEl = document.getElementById('accountModalStatus');
statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
@ -2427,9 +2547,18 @@ async function saveAccount() {
} }
// Save credentials // Save credentials
const hosters = { ...config.hosters }; if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = [];
hosters[hosterName] = creds; if (editingAccountId) {
await window.api.saveConfig({ hosters }); // Update existing account in array
const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId);
if (idx >= 0) {
config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds };
}
} else {
// Add new account
config.hosters[hosterName].push({ id: accountId, ...creds });
}
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig(); config = await window.api.getConfig();
// Show checking status // Show checking status
@ -2439,30 +2568,31 @@ async function saveAccount() {
statusEl.className = 'account-modal-status checking'; statusEl.className = 'account-modal-status checking';
saveBtn.disabled = true; saveBtn.disabled = true;
accountStatuses[hosterName] = { status: 'checking', message: '' }; accountStatuses[accountId] = { status: 'checking', message: '' };
syncSelectedUploadHosters(); syncSelectedUploadHosters();
renderAccounts(); renderAccounts();
renderHosterSummary(); renderHosterSummary();
renderHosterModal(); renderHosterModal();
renderSettings(); renderSettings();
// Run health check // Run health check for this specific account
try { try {
const rows = await executeHealthCheck([hosterName], 'auto'); const result = await window.api.runHealthCheck({ hosters: [{ hoster: hosterName, accountId }] });
const row = rows.find(r => r.hoster === hosterName); const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId);
if (row && (row.status === 'ok' || row.status === 'warn')) { if (row && (row.status === 'ok' || row.status === 'warn')) {
accountStatuses[hosterName] = { status: row.status || 'ok', message: row.message || '' }; accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' };
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!';
statusEl.className = 'account-modal-status ok'; statusEl.className = 'account-modal-status ok';
setTimeout(() => closeAccountModal(), 1200); setTimeout(() => closeAccountModal(), 1200);
} else { } else {
const msg = (row && row.message) || 'Login fehlgeschlagen'; const msg = (row && row.message) || 'Login fehlgeschlagen';
accountStatuses[hosterName] = { status: 'error', message: msg }; accountStatuses[accountId] = { status: 'error', message: msg };
statusEl.textContent = msg; statusEl.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} }
} catch (err) { } catch (err) {
accountStatuses[hosterName] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; statusEl.textContent = err.message || 'Prüfung fehlgeschlagen';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} finally { } finally {
@ -2822,8 +2952,10 @@ function setupListeners() {
// Account hoster select change → update credential fields // Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => { document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(e.target.value, {}); credsContainer.innerHTML = getCredsFieldsHtml(authType, {});
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const input = btn.previousElementSibling; const input = btn.previousElementSibling;
@ -2839,8 +2971,8 @@ function setupListeners() {
document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal); document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal);
document.getElementById('confirmDeleteBtn').addEventListener('click', () => { document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
const modal = document.getElementById('deleteAccountModal'); const modal = document.getElementById('deleteAccountModal');
const hoster = modal.dataset.hoster; const accountId = modal.dataset.accountId;
if (hoster) deleteAccount(hoster); if (accountId) deleteAccount(accountId);
}); });
document.getElementById('deleteAccountModal').addEventListener('click', (e) => { document.getElementById('deleteAccountModal').addEventListener('click', (e) => {
if (e.target.id === 'deleteAccountModal') closeDeleteModal(); if (e.target.id === 'deleteAccountModal') closeDeleteModal();

View File

@ -812,6 +812,44 @@ body {
.account-modal-status.ok { color: var(--success); } .account-modal-status.ok { color: var(--success); }
.account-modal-status.error { color: var(--danger); } .account-modal-status.error { color: var(--danger); }
/* Multi-account: drag handle, priority badge, hoster group */
.account-card-drag-handle {
cursor: grab;
font-size: 14px;
color: var(--text-dim);
padding: 2px 4px;
flex-shrink: 0;
user-select: none;
}
.account-card-drag-handle:hover { color: var(--text-muted); }
.account-card.dragging { opacity: 0.4; }
.account-card.drag-over-above { border-top: 2px solid var(--accent); }
.account-card.drag-over-below { border-bottom: 2px solid var(--accent); }
.account-priority-badge {
font-size: 10px;
font-weight: 500;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
.account-hoster-group {
margin-bottom: 12px;
}
.account-hoster-group-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
padding-left: 4px;
}
.account-hoster-group .account-card { margin-bottom: 4px; }
.accounts-empty { .accounts-empty {
text-align: center; text-align: center;
padding: 48px 16px; padding: 48px 16px;