Multi-Hoster-Upload/lib/config-store.js
Administrator 644ed712e0 fix(rotation): persist the round-robin cursor so drip-fed uploads keep rotating
v3.3.74 created a fresh account picker inside every buildUploadTasks /
buildUploadTasksFromJobs call, so its in-memory rotation index reset to 0
on each call. That is correct for a one-shot batch (drag-drop N files at
once distributes fine), but it silently no-ops in exactly the pattern the
byse 80-uploads/30-days quota cares about: the folder monitor feeds new
files into a *running* batch one detection at a time via add-jobs-to-batch
(renderer -> add-jobs-to-batch IPC), and per-detection autoStart likewise
fires start-upload per file. Each of those calls rebuilt the picker from
index 0, so account 1 won every single time and the secondary accounts
never received anything.

The rotation cursor now lives outside the picker and survives across calls
and across app restarts (the quota spans a rolling 30-day window, so a
session-only counter would still starve secondaries for users who restart
between small uploads):

- account-rotation.js: createAccountPicker() accepts a seed { indices } map,
  resumes the per-hoster cursor from it, and exposes indices() (the advanced
  cursors) + dirty() (whether any rotation actually happened this call). The
  cursor is a monotonic counter taken mod the current enabled-account count,
  so it keeps wrapping correctly even if an account is later enabled/disabled.
- config-store.js: new top-level rotationCursors map (added to DEFAULTS, read
  back in load() which otherwise reconstructs the result and would drop
  unknown keys) plus a saveRotationCursors() method. The existing
  read-modify-write save() preserves it across unrelated settings/credential
  saves; secret-store never touches it.
- main.js: a module-level _rotationCursors is the authoritative source of
  truth (seeded once from disk on first use, updated synchronously per batch),
  with config as restart-survival backing. makeAccountPicker() seeds the
  picker from it; persistRotation() folds the advance back and flushes to
  config only when dirty. Authoritative in-memory state also closes the
  disk-read race two rapid batches would otherwise hit. Both task builders
  now receive the picker instead of constructing their own.

Composition with failover is unchanged and verified: the picker only chooses
each file's *initial* account and is never called on the failover path. The
pre-job-swap reroutes a task only when that task's own account is in
_failedAccounts, so a dead account's jobs reroute while healthy accounts keep
their rotation share. No double-advance of the cursor.

Tests: account-rotation gains seeded-resume, drip-feed-across-pickers (the
regression), dirty()-semantics, carry-forward, and count-shrink-wrap cases;
config-store gains rotationCursors default, save round-trip, no-clobber, and
credentials-undisturbed cases. Full suite 303/303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:33:20 +02:00

421 lines
16 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const secretStore = require('./secret-store');
const { normalizeLogMode } = require('./log-mode');
const HOSTER_SETTINGS_DEFAULTS = {
retries: 3,
maxSpeedKbs: 0, // 0 = unlimited
parallelCount: 2, // 1-100
restartBelowKbs: 0, // 0 = off
timeIntervalSec: 0, // delay between jobs
maxSizeMb: 0, // 0 = unlimited
logToFile: true, // write this hoster's successful links to fileuploader.log
rotateAccounts: false
};
// 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: '' },
'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' }
};
// All known hoster names (used for iteration)
const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
// 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' },
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
];
const DEFAULTS = {
hosters: {
'doodstream.com': [],
'voe.sx': [],
'vidmoly.me': [],
'byse.sx': [],
'clouddrop.cc': []
},
hosterSettings: {
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS }
},
globalSettings: {
alwaysOnTop: false,
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
logFilePath: '',
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
webhookUrl: '', // POST target on batch-done (Discord or generic JSON)
webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone
autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end
autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay)
historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
// would seed logMode='single' for every load, which would beat (and silently
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
// load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true,
parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false,
removeFromQueueOnDone: false,
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
scramble: {
active: false,
prefix: '',
suffix: '',
chars: 'both', // 'letters' | 'numbers' | 'both'
length: 0 // 0 = same as original basename length
},
folderMonitor: {
enabled: false,
folderPath: '',
recursive: false,
filterMode: 'include', // 'include' | 'exclude'
extensions: '', // comma-separated: 'mp4,mkv,avi'
skipDuplicates: true,
delaySec: 3,
autoStart: true,
hosters: [] // pre-selected hosters, empty = ask via modal
},
remote: {
enabled: false,
port: 9100,
token: '',
allowInput: true
}
},
history: [],
rotationCursors: {}
};
const HISTORY_RETENTION_OPTIONS = [
{ value: 'all', label: 'Alles behalten' },
{ value: '7d', label: 'Letzte 7 Tage' },
{ value: '30d', label: 'Letzte 30 Tage' },
{ value: '90d', label: 'Letzte 90 Tage' },
{ value: '1000', label: 'Letzte 1000 Uploads' },
{ value: '100', label: 'Letzte 100 Uploads' }
];
function batchTimestampMs(batch) {
const raw = batch && batch.timestamp;
if (raw === null || raw === undefined || raw === '') return null;
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
return Number.isFinite(ms) ? ms : null;
}
function batchRowCount(batch) {
let n = 0;
const files = (batch && batch.files) || [];
for (const file of files) {
for (const result of (file.results || [])) {
if (result.status === 'aborted' || result.status === 'error') continue;
n++;
}
}
return n;
}
function countHistoryRows(history) {
let n = 0;
for (const batch of (history || [])) n += batchRowCount(batch);
return n;
}
function applyHistoryRetention(history, retention, nowMs) {
if (!Array.isArray(history) || history.length === 0) return history;
const policy = String(retention || 'all');
if (policy === 'all') return history;
if (/^\d+d$/.test(policy)) {
const days = parseInt(policy, 10);
if (!Number.isFinite(days) || days <= 0) return history;
const cutoff = nowMs - days * 86400000;
return history.filter(b => {
const ts = batchTimestampMs(b);
return ts === null || ts >= cutoff;
});
}
const maxRows = parseInt(policy, 10);
if (!Number.isFinite(maxRows) || maxRows <= 0) return history;
const keptReversed = [];
let acc = 0;
for (let i = history.length - 1; i >= 0; i--) {
keptReversed.push(history[i]);
acc += batchRowCount(history[i]);
if (acc >= maxRows) break;
}
return keptReversed.reverse();
}
class ConfigStore {
constructor(app) {
const dir = app && app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '..');
this.filePath = path.join(dir, 'electron-config.json');
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
// Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
this._migrateFromOldPath(app);
}
}
_migrateFromOldPath(app) {
try {
const appDataDir = path.dirname(app.getPath('userData'));
// Check alternate folder names that may have been used
const candidates = ['multi-hoster-uploader', 'Multi-Hoster-Upload'];
for (const name of candidates) {
const oldPath = path.join(appDataDir, name, 'electron-config.json');
if (oldPath !== this.filePath && fs.existsSync(oldPath)) {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.copyFileSync(oldPath, this.filePath);
return;
}
}
// Also check next to the executable (portable mode previous location)
const exeDir = path.dirname(app.getPath('exe'));
const portablePath = path.join(exeDir, 'electron-config.json');
if (portablePath !== this.filePath && fs.existsSync(portablePath)) {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.copyFileSync(portablePath, this.filePath);
}
} catch {}
}
_readAndParse(filePath) {
const raw = fs.readFileSync(filePath, 'utf-8');
if (!raw || raw.trim().length < 2) return null;
return JSON.parse(raw);
}
load() {
try {
let data = null;
// Try main config
try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt
if (!data) {
const backupPath = this.filePath + '.bak';
try { data = this._readAndParse(backupPath); } catch {}
}
if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
}
// Migrate old single-object format to array format
for (const [name, val] of Object.entries(data.hosters || {})) {
if (val && !Array.isArray(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
const hosterSettings = {};
for (const name of Object.keys(DEFAULTS.hosterSettings)) {
hosterSettings[name] = {
...HOSTER_SETTINGS_DEFAULTS,
...(data.hosterSettings && data.hosterSettings[name] || {})
};
}
const savedGlobal = data.globalSettings || {};
const globalSettings = {
...DEFAULTS.globalSettings,
...savedGlobal
};
// Deep-merge nested objects so new keys are always present
for (const key of Object.keys(DEFAULTS.globalSettings)) {
const def = DEFAULTS.globalSettings[key];
if (def && typeof def === 'object' && !Array.isArray(def)) {
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
}
}
// Normalize logMode at this single boundary. Legacy sessionLog: true
// means *daily* (the old field was named after a misnomer); see log-mode.js.
// Downstream readers consume logMode only and must NOT derive from
// sessionLog at call sites.
globalSettings.logMode = normalizeLogMode(globalSettings);
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
? data.rotationCursors
: {};
const result = { hosters, hosterSettings, globalSettings, history: data.history || [], rotationCursors };
// Decrypt credentials stored with safeStorage so the rest of the app
// keeps working with plaintext in memory.
secretStore.decryptCredentials(result);
return result;
} catch {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
}
}
// Deep-clone a config and encrypt its credential fields. Never mutate the
// caller's object — the rest of the app holds plaintext references.
_serializeForDisk(config) {
const clone = JSON.parse(JSON.stringify(config));
secretStore.encryptCredentials(clone);
return JSON.stringify(clone, null, 2);
}
_enqueueWrite(fn) {
this._writeQueue = this._writeQueue.then(fn, fn);
return this._writeQueue;
}
save(config) {
return this._enqueueWrite(() => {
const current = this.load();
if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings;
return this._atomicWrite(this._serializeForDisk(current));
});
}
loadHistory() {
const config = this.load();
return config.history || [];
}
_atomicWrite(data) {
return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
if (err) return reject(err);
try {
// Refresh .bak from the previous live file. Wrapped in try/catch
// so an AV/indexer briefly locking the file doesn't fail the whole
// save — the rename to the live path is the part that matters,
// a stale .bak is preferable to losing the new write entirely.
try {
if (fs.existsSync(this.filePath)) {
const existing = fs.readFileSync(this.filePath, 'utf-8');
if (existing && existing.trim().length > 2) {
let isValid = false;
try {
const parsed = JSON.parse(existing);
isValid = parsed && typeof parsed === 'object' && (parsed.hosters || parsed.hosterSettings || parsed.globalSettings);
} catch {}
if (isValid) fs.writeFileSync(backupPath, existing, 'utf-8');
}
}
} catch {}
fs.renameSync(tmpPath, this.filePath);
} catch (e) { return reject(e); }
resolve();
});
});
}
appendHistory(entry) {
return this._enqueueWrite(() => {
const config = this.load();
config.history.push(entry);
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
config.history = applyHistoryRetention(config.history, retention, Date.now());
return this._atomicWrite(this._serializeForDisk(config));
});
}
pruneHistory(retention, opts = {}) {
const dryRun = !!opts.dryRun;
return this._enqueueWrite(() => {
const config = this.load();
const beforeBatches = config.history.length;
const beforeRows = countHistoryRows(config.history);
const pruned = applyHistoryRetention(config.history, retention, Date.now());
const result = {
removedBatches: beforeBatches - pruned.length,
removedRows: beforeRows - countHistoryRows(pruned),
keptBatches: pruned.length,
keptRows: countHistoryRows(pruned)
};
if (dryRun) return result;
config.history = pruned;
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
return this._atomicWrite(this._serializeForDisk(config)).then(() => result);
});
}
clearHistory() {
return this._enqueueWrite(() => {
const config = this.load();
config.history = [];
return this._atomicWrite(this._serializeForDisk(config));
});
}
saveRotationCursors(cursors) {
return this._enqueueWrite(() => {
const config = this.load();
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
return this._atomicWrite(this._serializeForDisk(config));
});
}
}
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;
module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS;
module.exports.applyHistoryRetention = applyHistoryRetention;
module.exports.countHistoryRows = countHistoryRows;