Compare commits

..

2 Commits

Author SHA1 Message Date
Administrator
950f103f99 release: v3.3.74 2026-06-17 03:22:31 +02:00
Administrator
3204629fef feat(rotation): opt-in per-hoster account rotation to keep all accounts active
byse.sx requires each account to upload >=80 videos in the last 30 days
(measured across a rolling 3-month window) or it goes dormant. With a
single primary account doing all the work, the other configured accounts
decay and eventually get suspended. This adds an opt-in "Accounts rotieren"
toggle per hoster that round-robins files across every enabled account that
has credentials: file 1 -> account 1, file 2 -> account 2, file 3 ->
account 3, then wraps. Off by default, so existing single-account behavior
is unchanged.

Mechanics:
- New lib/account-rotation.js: createAccountPicker() returns a stateful
  pick(hoster) closure holding a per-hoster round-robin index. It only
  rotates when rotateAccounts === true AND more than one usable account
  exists; otherwise it returns the first enabled account (the old primary).
  enabledAccountsFor() filters disabled + credential-less accounts while
  preserving configured order, so a disabled account is simply skipped in
  the cycle rather than leaving a gap.
- main.js: both buildUploadTasks() and buildUploadTasksFromJobs() now build
  one picker per call and use pick(hoster) instead of getPrimaryAccount(),
  which is now removed (dead code). A fresh picker per batch means each
  upload session starts the cycle at account 1, matching "die erste Datei
  auf Account eins".
- config-store.js: rotateAccounts: false added to HOSTER_SETTINGS_DEFAULTS.
- renderer/app.js: "Accounts rotieren" checkbox in the per-hoster upload
  settings (Accounts tab). Persisted by the existing generic checkbox path
  in saveHosterSettingsFromDom().

Composes with failover: rotation only chooses the *initial* account per
file. On a hard account failure the existing failover (_failedAccounts +
pre-job account swap) reroutes just that account's jobs; the healthy
accounts keep their rotation share.

Single enabled account (or rotation off) = no behavior change.

Tests: tests/account-rotation.test.js (10 cases) covers off=primary,
on=round-robin+wrap, single-account no-op, skip disabled, skip no-creds,
null when none usable, per-hoster index independence, byse-only rotation,
100-file 50/50 split, and the enabledAccountsFor filter. Full suite 294/294.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:20:50 +02:00
6 changed files with 119 additions and 15 deletions

22
lib/account-rotation.js Normal file
View File

@ -0,0 +1,22 @@
function enabledAccountsFor(hosters, hoster, hasCreds) {
const list = hosters && hosters[hoster];
if (!Array.isArray(list)) return [];
return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a));
}
function createAccountPicker({ hosters, hosterSettings, hasCreds }) {
const rotIdx = Object.create(null);
return function pick(hoster) {
const enabled = enabledAccountsFor(hosters, hoster, hasCreds);
if (enabled.length === 0) return null;
const hs = (hosterSettings && hosterSettings[hoster]) || {};
if (hs.rotateAccounts === true && enabled.length > 1) {
const i = (rotIdx[hoster] || 0) % enabled.length;
rotIdx[hoster] = (rotIdx[hoster] || 0) + 1;
return enabled[i];
}
return enabled[0];
};
}
module.exports = { createAccountPicker, enabledAccountsFor };

View File

@ -10,7 +10,8 @@ const HOSTER_SETTINGS_DEFAULTS = {
restartBelowKbs: 0, // 0 = off restartBelowKbs: 0, // 0 = off
timeIntervalSec: 0, // delay between jobs timeIntervalSec: 0, // delay between jobs
maxSizeMb: 0, // 0 = unlimited maxSizeMb: 0, // 0 = unlimited
logToFile: true // write this hoster's successful links to fileuploader.log logToFile: true, // write this hoster's successful links to fileuploader.log
rotateAccounts: false
}; };
// Template for each hoster type (used as defaults for new accounts) // Template for each hoster type (used as defaults for new accounts)

25
main.js
View File

@ -9,6 +9,7 @@ const VidmolyUploader = require('./lib/vidmoly-upload');
const VoeUploader = require('./lib/voe-upload'); const VoeUploader = require('./lib/voe-upload');
const DoodstreamUploader = require('./lib/doodstream-upload'); const DoodstreamUploader = require('./lib/doodstream-upload');
const { selectUploadAuth } = require('./lib/account-auth'); const { selectUploadAuth } = require('./lib/account-auth');
const { createAccountPicker } = require('./lib/account-rotation');
const ClouddropUploader = require('./lib/clouddrop-upload'); const ClouddropUploader = require('./lib/clouddrop-upload');
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
const backupCrypto = require('./lib/backup-crypto'); const backupCrypto = require('./lib/backup-crypto');
@ -712,12 +713,6 @@ function hosterAccountHasCreds(name, account) {
return !!account.apiKey; return !!account.apiKey;
} }
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;
}
function getNextFallbackAccount(config, hosterName, failedAccountId) { function getNextFallbackAccount(config, hosterName, failedAccountId) {
const accounts = config.hosters[hosterName]; const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return null; if (!Array.isArray(accounts)) return null;
@ -749,9 +744,10 @@ function buildTaskFromAccount(hoster, account, extra) {
function buildUploadTasks(config, files, hosters) { function buildUploadTasks(config, files, hosters) {
const tasks = []; const tasks = [];
const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds });
for (const file of files) { for (const file of files) {
for (const hoster of hosters) { for (const hoster of hosters) {
const account = getPrimaryAccount(config, hoster); const account = pick(hoster);
if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; } if (!account) { debugLog(` skip ${hoster}: no enabled account with creds`); continue; }
tasks.push(buildTaskFromAccount(hoster, account, { file })); tasks.push(buildTaskFromAccount(hoster, account, { file }));
} }
@ -761,12 +757,15 @@ function buildUploadTasks(config, files, hosters) {
function buildUploadTasksFromJobs(config, jobs) { function buildUploadTasksFromJobs(config, jobs) {
if (!Array.isArray(jobs)) return []; if (!Array.isArray(jobs)) return [];
return jobs.flatMap((job) => { const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds });
if (!job || !job.file || !job.hoster) return []; const tasks = [];
const account = getPrimaryAccount(config, job.hoster); for (const job of jobs) {
if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); return []; } if (!job || !job.file || !job.hoster) continue;
return [buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })]; const account = pick(job.hoster);
}); if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); continue; }
tasks.push(buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null }));
}
return tasks;
} }
async function checkDoodstreamHealth(hosterConfig, otp) { async function checkDoodstreamHealth(hosterConfig, otp) {

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.73", "version": "3.3.74",
"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

@ -3673,6 +3673,11 @@ function _buildHosterSettingsHtml(name) {
<input type="checkbox" class="hs-input" data-hoster="${name}" data-hs="logToFile" ${hs.logToFile !== false ? 'checked' : ''}> <input type="checkbox" class="hs-input" data-hoster="${name}" data-hs="logToFile" ${hs.logToFile !== false ? 'checked' : ''}>
<span class="hint">Erfolgreiche Links in fileuploader.log.</span> <span class="hint">Erfolgreiche Links in fileuploader.log.</span>
</div> </div>
<div class="settings-row">
<label>Accounts rotieren</label>
<input type="checkbox" class="hs-input" data-hoster="${name}" data-hs="rotateAccounts" ${hs.rotateAccounts === true ? 'checked' : ''}>
<span class="hint">Verteilt die Dateien reihum auf alle aktiven Accounts dieses Hosters (Datei 1 Account 1, Datei 2 Account 2 ). Hält z. B. byse-Accounts aktiv. Nur ein Account = kein Effekt.</span>
</div>
</div> </div>
</div> </div>
</div>`; </div>`;

View File

@ -0,0 +1,77 @@
const test = require('node:test');
const assert = require('node:assert');
const { createAccountPicker, enabledAccountsFor } = require('../lib/account-rotation');
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
function picks(pick, hoster, n) { return Array.from({ length: n }, () => { const a = pick(hoster); return a ? a.id : null; }); }
test('rotate OFF: always the first enabled account (primary, unchanged behavior)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a1', 'a1', 'a1']);
});
test('rotate ON, 3 accounts: round-robin per call and wraps around', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 7), ['a1', 'a2', 'a3', 'a1', 'a2', 'a3', 'a1']);
});
test('rotate ON, single enabled account: no-op (length must be > 1 to rotate)', () => {
const hosters = { 'byse.sx': [acc('a1')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a1', 'a1', 'a1']);
});
test('rotate ON skips a disabled account, keeps the rest in order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('rotate ON skips an account without credentials', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { creds: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('no usable account → null (disabled hoster or missing hoster)', () => {
const hosters = { 'byse.sx': [acc('a1', { enabled: false })] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.strictEqual(pick('byse.sx'), null);
assert.strictEqual(pick('voe.sx'), null);
});
test('rotation index is independent per hoster (interleaved calls)', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const settings = { 'byse.sx': { rotateAccounts: true }, 'voe.sx': { rotateAccounts: true } };
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds });
assert.strictEqual(pick('byse.sx').id, 'b1');
assert.strictEqual(pick('voe.sx').id, 'v1');
assert.strictEqual(pick('byse.sx').id, 'b2');
assert.strictEqual(pick('voe.sx').id, 'v2');
assert.strictEqual(pick('byse.sx').id, 'b1');
});
test('rotate ON for byse only: voe still uses its primary', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'voe.sx', 2), ['v1', 'v1']);
assert.deepStrictEqual(picks(pick, 'byse.sx', 2), ['b1', 'b2']);
});
test('user scenario: 100 files across 2 active accounts → even 50/50 alternating split', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
const ids = picks(pick, 'byse.sx', 100);
assert.strictEqual(ids.filter(x => x === 'a1').length, 50);
assert.strictEqual(ids.filter(x => x === 'a2').length, 50);
assert.deepStrictEqual(ids.slice(0, 5), ['a1', 'a2', 'a1', 'a2', 'a1']);
});
test('enabledAccountsFor filters disabled + no-creds and preserves order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3', { creds: false }), acc('a4')] };
assert.deepStrictEqual(enabledAccountsFor(hosters, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']);
assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []);
});