diff --git a/lib/account-rotation.js b/lib/account-rotation.js new file mode 100644 index 0000000..0bf0d5a --- /dev/null +++ b/lib/account-rotation.js @@ -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 }; diff --git a/lib/config-store.js b/lib/config-store.js index 9b43075..066d440 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -10,7 +10,8 @@ const HOSTER_SETTINGS_DEFAULTS = { restartBelowKbs: 0, // 0 = off timeIntervalSec: 0, // delay between jobs 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) diff --git a/main.js b/main.js index bd93688..3027328 100644 --- a/main.js +++ b/main.js @@ -9,6 +9,7 @@ const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); const DoodstreamUploader = require('./lib/doodstream-upload'); const { selectUploadAuth } = require('./lib/account-auth'); +const { createAccountPicker } = require('./lib/account-rotation'); const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); @@ -712,12 +713,6 @@ function hosterAccountHasCreds(name, account) { 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) { const accounts = config.hosters[hosterName]; if (!Array.isArray(accounts)) return null; @@ -749,9 +744,10 @@ function buildTaskFromAccount(hoster, account, extra) { function buildUploadTasks(config, files, hosters) { const tasks = []; + const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds }); for (const file of files) { 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; } tasks.push(buildTaskFromAccount(hoster, account, { file })); } @@ -761,12 +757,15 @@ function buildUploadTasks(config, files, hosters) { function buildUploadTasksFromJobs(config, jobs) { if (!Array.isArray(jobs)) return []; - return jobs.flatMap((job) => { - if (!job || !job.file || !job.hoster) return []; - const account = getPrimaryAccount(config, job.hoster); - if (!account) { debugLog(` skip ${job.hoster}: no enabled account`); return []; } - return [buildTaskFromAccount(job.hoster, account, { file: job.file, jobId: job.id || job.jobId || null })]; - }); + const pick = createAccountPicker({ hosters: config.hosters, hosterSettings: config.hosterSettings, hasCreds: hosterAccountHasCreds }); + const tasks = []; + for (const job of jobs) { + if (!job || !job.file || !job.hoster) continue; + 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) { diff --git a/renderer/app.js b/renderer/app.js index 92c7b65..eabc32f 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3673,6 +3673,11 @@ function _buildHosterSettingsHtml(name) { Erfolgreiche Links in fileuploader.log. +
+ + + 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. +
`; diff --git a/tests/account-rotation.test.js b/tests/account-rotation.test.js new file mode 100644 index 0000000..1b6eb01 --- /dev/null +++ b/tests/account-rotation.test.js @@ -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), []); +});