release: Multi-Hoster-Upload v2.0.4

This commit is contained in:
Sucukdeluxe
2026-08-09 15:52:23 +02:00
parent 1c4d096cb9
commit 48891503ad
10 changed files with 184 additions and 29 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-hoster-uploader",
"version": "2.0.3",
"version": "2.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "2.0.3",
"version": "2.0.4",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
+36
View File
@@ -0,0 +1,36 @@
(function (scope) {
const STATUS_PRESENTATIONS = {
ok: { statusClass: 'ok', label: 'Bereit', requiresOtp: false },
warn: { statusClass: 'warn', label: 'Warnung', requiresOtp: false },
checking: { statusClass: 'checking', label: 'Prüfe...', requiresOtp: false },
error: { statusClass: 'error', label: 'Fehler', requiresOtp: false },
otp_required: { statusClass: 'warn', label: 'OTP erforderlich', requiresOtp: true },
unchecked: { statusClass: 'unchecked', label: 'Nicht geprüft', requiresOtp: false },
disabled: { statusClass: 'disabled', label: 'Deaktiviert', requiresOtp: false }
};
function getAccountStatusPresentation(status) {
const presentation = STATUS_PRESENTATIONS[status] || STATUS_PRESENTATIONS.unchecked;
return { ...presentation };
}
function getAccountGroupStatus(summary) {
const total = Math.max(0, Number(summary && summary.total) || 0);
const disabled = Math.min(total, Math.max(0, Number(summary && summary.disabled) || 0));
const active = total - disabled;
const errors = Math.max(0, Number(summary && summary.error) || 0);
const ok = Math.max(0, Number(summary && summary.ok) || 0);
const warning = Math.max(0, Number(summary && summary.warn) || 0);
const checking = Math.max(0, Number(summary && summary.checking) || 0);
const unchecked = Math.max(0, Number(summary && summary.unchecked) || 0);
if (active === 0) return 'unchecked';
if (errors >= active) return 'error';
if (errors > 0 || warning > 0 || checking > 0 || unchecked > 0) return 'warn';
if (ok >= active) return 'ok';
return 'warn';
}
const accountStatus = { getAccountGroupStatus, getAccountStatusPresentation };
if (typeof module !== 'undefined' && module.exports) module.exports = accountStatus;
if (scope) scope.AccountStatus = accountStatus;
})(typeof window !== 'undefined' ? window : globalThis);
+2 -2
View File
@@ -1,6 +1,6 @@
(function (scope) {
function getAccountSubmitLabel({ isEdit } = {}) {
return isEdit ? 'Prüfen und speichern' : 'Prüfen und anlegen';
function getAccountSubmitLabel() {
return 'Prüfen und speichern';
}
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
+57 -15
View File
@@ -3920,13 +3920,12 @@ function getCredentialLabel(name, account) {
return 'Keine Zugangsdaten';
}
const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
function _buildAccountCardHtml(name, account, idx) {
const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
const statusClass = isDisabled ? 'disabled' : st.status;
const statusPresentation = window.AccountStatus.getAccountStatusPresentation(isDisabled ? 'disabled' : st.status);
const statusLabel = statusPresentation.label;
const statusClass = statusPresentation.statusClass;
const credLabel = getCredentialLabel(name, account);
const userLabel = account.label && String(account.label).trim();
// Subtitle: "Label: XYZ • API: ABC… • <status>" — the user-set label is the
@@ -3940,6 +3939,12 @@ function _buildAccountCardHtml(name, account, idx) {
const sessionPausedBadge = isSessionPaused
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.">Pausiert (Session) <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren"></button></span>`
: '';
const otpAction = !isDisabled && statusPresentation.requiresOtp
? `<div class="account-otp-action">
<input class="key-input account-otp-input" data-account-otp-input="${escapeAttr(account.id)}" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="10" placeholder="Code aus E-Mail">
<button class="btn btn-xs btn-primary" data-account-otp-submit="${escapeAttr(account.id)}">Prüfen und speichern</button>
</div>`
: '';
return `
<div class="account-card${isDisabled ? ' account-disabled' : ''}${isSessionPaused ? ' account-session-paused-card' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
@@ -3947,6 +3952,7 @@ function _buildAccountCardHtml(name, account, idx) {
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
${otpAction}
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
@@ -3987,10 +3993,7 @@ function _refreshHosterGroupHeader(name) {
if (!group) return;
const accounts = config.hosters[name] || [];
const summary = _summarizeHosterGroup(accounts);
let dot = 'unchecked';
if (summary.error > 0) dot = 'error';
else if (summary.checking > 0) dot = 'checking';
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
const dot = window.AccountStatus.getAccountGroupStatus(summary);
const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot');
if (dotEl) dotEl.className = `account-status-dot status-${dot}`;
const countEl = group.querySelector('.account-hoster-group-count');
@@ -4060,16 +4063,17 @@ function renderAccounts() {
}
function _summarizeHosterGroup(accounts) {
let ok = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
let ok = 0, warn = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
for (const a of accounts) {
if (a.enabled === false) { disabled++; continue; }
const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked';
if (s === 'ok' || s === 'warn') ok++;
if (s === 'ok') ok++;
else if (s === 'warn' || s === 'otp_required') warn++;
else if (s === 'error') error++;
else if (s === 'checking') checking++;
else unchecked++;
}
return { ok, error, checking, unchecked, disabled, total: accounts.length };
return { ok, warn, error, checking, unchecked, disabled, total: accounts.length };
}
function _hosterGroupOpenState(name, summary) {
@@ -4192,10 +4196,7 @@ function _buildHosterSettingsHtml(name) {
function _buildAccountHosterGroupHtml(name, accounts) {
const summary = _summarizeHosterGroup(accounts);
const isOpen = _hosterGroupOpenState(name, summary);
let dot = 'unchecked';
if (summary.error > 0) dot = 'error';
else if (summary.checking > 0) dot = 'checking';
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
const dot = window.AccountStatus.getAccountGroupStatus(summary);
const countLabel = `${summary.ok}/${summary.total}`;
const arrow = isOpen ? '&#9660;' : '&#9654;';
let cardsHtml = '';
@@ -4299,6 +4300,7 @@ function bindAccountListeners(container) {
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
if (btn.dataset.accountOtpSubmit) return submitAccountOtp(btn.dataset.accountOtpSubmit);
if (btn.dataset.accountReactivate) {
const accountId = btn.dataset.accountReactivate;
const hoster = btn.dataset.accountReactivateHoster;
@@ -4413,6 +4415,46 @@ async function checkSingleAccount(accountId) {
updateAccountCard(accountId);
}
async function submitAccountOtp(accountId) {
if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found || found.account.enabled === false) return;
const card = Array.from(document.querySelectorAll('.account-card')).find(el => el.dataset.accountId === accountId);
const otpInput = card?.querySelector('[data-account-otp-input]');
const otp = otpInput?.value.trim() || '';
if (otpInput) otpInput.setCustomValidity('');
if (!otp) {
if (otpInput) {
otpInput.setCustomValidity('Bitte den OTP-Code eingeben.');
otpInput.reportValidity();
}
return;
}
const submitButton = card?.querySelector('[data-account-otp-submit]');
healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: 'OTP wird geprüft…' };
if (otpInput) otpInput.disabled = true;
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Prüfe…';
}
try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, otp }] });
const row = result && Array.isArray(result.results)
? result.results.find(item => item.accountId === accountId)
: null;
accountStatuses[accountId] = row
? { status: row.status || 'error', message: row.message || '' }
: { status: 'error', message: 'Keine Antwort vom Hoster erhalten' };
} catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' };
} finally {
healthCheckRunning = false;
updateAccountCard(accountId);
renderHosterModal();
}
}
// Per-hoster overrides for the login form. VOE only accepts emails — the
// generic "Username / E-Mail" label sent users down a confusing rabbit hole
// (login fails → upload fetches login redirect → "CSRF token nicht gefunden").
+1
View File
@@ -425,6 +425,7 @@
<script src="../lib/throttle-timer.js"></script>
<script src="../lib/serialized-runner.js"></script>
<script src="account-submit.js"></script>
<script src="account-status.js"></script>
<script src="app.js"></script>
</body>
</html>
+3
View File
@@ -1004,6 +1004,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.account-card-info { flex: 1; min-width: 0; }
.account-card-title { font-size: 14px; font-weight: 600; }
.account-card-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-otp-action { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.account-otp-input { width: 150px; min-height: 28px; padding: 5px 8px; font-size: 11px; }
.account-card-actions { display: flex; gap: 6px; flex-shrink: 0; }
.account-status {
@@ -1184,6 +1186,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.account-hoster-group .account-card:last-child { margin-bottom: 0; }
.account-status-dot.status-ok { background: #4caf50; }
.account-status-dot.status-error { background: #e57373; }
.account-status-dot.status-warn { background: #f0c36c; }
.account-status-dot.status-checking { background: #f0c36c; }
.account-status-dot.status-unchecked { background: #6c757d; }
.account-hoster-group-header .account-status-dot {
+30
View File
@@ -0,0 +1,30 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
getAccountGroupStatus,
getAccountStatusPresentation
} = require('../renderer/account-status');
test('mixed account results use warning group status', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 2, error: 1, checking: 0, unchecked: 0 }), 'warn');
});
test('group is red only when every active account has an error', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 0, error: 3, checking: 0, unchecked: 0 }), 'error');
});
test('group is green when every active account is ready', () => {
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 3, error: 0, checking: 0, unchecked: 0 }), 'ok');
});
test('warning-only accounts keep the group orange', () => {
assert.equal(getAccountGroupStatus({ total: 2, disabled: 0, ok: 0, warn: 2, error: 0, checking: 0, unchecked: 0 }), 'warn');
});
test('OTP-required account exposes warning presentation', () => {
assert.deepEqual(getAccountStatusPresentation('otp_required'), {
statusClass: 'warn',
label: 'OTP erforderlich',
requiresOtp: true
});
});
+50 -7
View File
@@ -109,10 +109,10 @@ setTimeout(async () => {
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
check('Add account button enabled', addAccountEnabled);
const emptyAccountAction = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]")?.textContent?.trim()');
check('Empty account state offers direct action', emptyAccountAction === 'Ersten Account hinzufügen');
const emptyAccountAction = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]")?.textContent?.trim() || document.getElementById("addAccountBtn")?.textContent?.trim()');
check('Account list offers direct add action', emptyAccountAction === 'Ersten Account hinzufügen' || emptyAccountAction === 'Account hinzufügen');
await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click()');
await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger?.focus(); trigger?.click(); })()');
await new Promise(r => setTimeout(r, 200));
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
@@ -143,7 +143,7 @@ setTimeout(async () => {
check('5 hosters exist', hosterCount === 5);
const accountSubmitLabel = await wc.executeJavaScript('document.getElementById("saveAccountBtn")?.textContent');
check('Account submit label is Prüfen und anlegen', accountSubmitLabel === 'Prüfen und anlegen');
check('Account submit label is Prüfen und speichern', accountSubmitLabel === 'Prüfen und speichern');
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
check('Credential inputs rendered', credentialInputs === 2);
@@ -155,12 +155,56 @@ setTimeout(async () => {
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Escape closes account modal', accountModalHidden === 'none');
const restoredAccountFocus = await wc.executeJavaScript('document.activeElement?.hasAttribute("data-account-empty-add")');
const restoredAccountFocus = await wc.executeJavaScript('document.activeElement?.hasAttribute("data-account-empty-add") || document.activeElement?.id === "addAccountBtn"');
check('Account modal restores trigger focus', restoredAccountFocus === true);
const fallbackAccountFocus = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click(); document.querySelector("[data-account-empty-add]").remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); document.activeElement?.id');
const fallbackAccountFocus = await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger.focus(); trigger.click(); document.querySelector("[data-account-empty-add]")?.remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); return document.activeElement?.id; })()');
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
const mixedGroupStatus = await wc.executeJavaScript(\`(() => {
config.hosters['byse.sx'] = [
{ id: 'ui-status-ok-1', enabled: true, authType: 'api', apiKey: 'key-one' },
{ id: 'ui-status-ok-2', enabled: true, authType: 'api', apiKey: 'key-two' },
{ id: 'ui-status-error', enabled: true, authType: 'api', apiKey: 'key-three' }
];
accountStatuses['ui-status-ok-1'] = { status: 'ok', message: 'Bereit' };
accountStatuses['ui-status-ok-2'] = { status: 'ok', message: 'Bereit' };
accountStatuses['ui-status-error'] = { status: 'error', message: 'Deaktiviert durch Administrator' };
renderAccounts();
const dot = document.querySelector('.account-hoster-group[data-hoster-group="byse.sx"] .account-hoster-group-header .account-status-dot');
return [dot?.className, getComputedStyle(dot).backgroundColor].join('|');
})()\`);
check('Mixed account results use orange group status', mixedGroupStatus === 'account-status-dot status-warn|rgb(240, 195, 108)');
const allErrorGroupStatus = await wc.executeJavaScript(\`(() => {
accountStatuses['ui-status-ok-1'] = { status: 'error', message: 'Fehler' };
accountStatuses['ui-status-ok-2'] = { status: 'error', message: 'Fehler' };
renderAccounts();
return document.querySelector('.account-hoster-group[data-hoster-group="byse.sx"] .account-hoster-group-header .account-status-dot')?.className;
})()\`);
check('All-error account group uses red status', allErrorGroupStatus === 'account-status-dot status-error');
const otpCardState = await wc.executeJavaScript(\`(() => {
config.hosters['doodstream.com'] = [{ id: 'ui-status-otp', enabled: true, authType: 'login', username: 'otp@example.com', password: 'password' }];
accountStatuses['ui-status-otp'] = { status: 'otp_required', message: 'OTP wurde an deine E-Mail gesendet.' };
renderAccounts();
const card = [...document.querySelectorAll('.account-card')].find(el => el.dataset.accountId === 'ui-status-otp');
return [
card?.querySelector('[data-account-otp-input]')?.getAttribute('placeholder'),
card?.querySelector('[data-account-otp-submit]')?.textContent?.trim(),
card?.querySelector('.account-status')?.textContent?.trim()
].join('|');
})()\`);
check('OTP-required account exposes inline code input and save action', otpCardState === 'Code aus E-Mail|Prüfen und speichern|OTP erforderlich');
const otpSubmitState = await wc.executeJavaScript(\`(() => {
const card = [...document.querySelectorAll('.account-card')].find(el => el.dataset.accountId === 'ui-status-otp');
card.querySelector('[data-account-otp-input]').value = '123456';
card.querySelector('[data-account-otp-submit]').click();
return [accountStatuses['ui-status-otp']?.status, String(card.querySelector('[data-account-otp-submit]')?.disabled)].join('|');
})()\`);
check('Inline OTP submission starts validation', otpSubmitState === 'checking|true');
console.log('\\n=== Settings View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
@@ -248,7 +292,6 @@ try {
const electronPath = path.join(__dirname, '..', 'node_modules', '.bin', 'electron');
const mainPath = path.join(__dirname, '..', 'main.js');
// We'll use --require to inject the test after the main process loads
const result = execSync(
`"${electronPath}" --require "${injectPath}" "${mainPath}"`,
{ cwd: path.join(__dirname, '..'), timeout: 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
+2 -2
View File
@@ -7,9 +7,9 @@ const {
} = require('../renderer/account-submit');
test('account submit labels stay exact for add, edit, and OTP retries', () => {
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
});