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>
78 lines
4.3 KiB
JavaScript
78 lines
4.3 KiB
JavaScript
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), []);
|
|
});
|