feat: add upload schedule windows

Add local weekly upload windows with overnight support, live settings updates, and a shared abortable upload gate.

Keep active transfers running while queued jobs wait outside the window without consuming attempts or parallel slots. Validate and localize the automation settings in German and English, including next-start status and hidden Electron coverage.
This commit is contained in:
Sucukdeluxe
2026-08-16 23:56:13 +02:00
parent 2eaa30c0a8
commit c8170b8556
11 changed files with 708 additions and 3 deletions
+9
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const secretStore = require('./secret-store');
const { normalizeLogMode } = require('./log-mode');
const { normalizeUploadSchedule } = require('./upload-schedule');
const HOSTER_SETTINGS_DEFAULTS = {
retries: 3,
@@ -84,6 +85,12 @@ const DEFAULTS = {
matchMode: 'all',
conditions: []
},
uploadSchedule: {
enabled: false,
weekdays: [1, 2, 3, 4, 5, 6, 0],
start: '00:00',
end: '23:59'
},
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
@@ -559,6 +566,7 @@ class ConfigStore {
// Downstream readers consume logMode only and must NOT derive from
// sessionLog at call sites.
globalSettings.logMode = normalizeLogMode(globalSettings);
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
? data.rotationCursors
: {};
@@ -588,6 +596,7 @@ class ConfigStore {
const hosters = this._clone(config.hosters || {});
const globalSettings = this._clone(config.globalSettings || {});
delete globalSettings.allowPlaintextCredentialStorage;
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
secretStore.encryptCredentials({ hosters });
return JSON.stringify({ ...config, globalSettings, hosters }, null, 2);
}
+23
View File
@@ -12,6 +12,7 @@ const Semaphore = require('./semaphore');
const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const { normalizeFailureDetails } = require('./upload-diagnostics');
const { createUploadScheduleGate } = require('./upload-schedule');
const DEFAULT_SETTINGS = {
retries: 3,
@@ -28,6 +29,7 @@ class UploadManager extends EventEmitter {
super();
this.hosterSettings = hosterSettings || {};
this.globalSettings = globalSettings || {};
this.uploadScheduleGate = createUploadScheduleGate(this.globalSettings.uploadSchedule);
this.accountPools = accountPools || {};
this.semaphores = {};
this.globalSemaphore = null;
@@ -305,6 +307,7 @@ class UploadManager extends EventEmitter {
updateSettings(hosterSettings, globalSettings) {
this.hosterSettings = hosterSettings || this.hosterSettings;
this.globalSettings = globalSettings || this.globalSettings;
this.uploadScheduleGate.update(this.globalSettings.uploadSchedule);
// Live-update semaphores for running uploads
for (const [hoster, sem] of Object.entries(this.semaphores)) {
const settings = this._getSettings(hoster);
@@ -633,6 +636,8 @@ class UploadManager extends EventEmitter {
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
@@ -1023,6 +1028,8 @@ class UploadManager extends EventEmitter {
// loop iterates: marks this account failed too, asks main for the next
// fallback, and so on.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@@ -1181,6 +1188,8 @@ class UploadManager extends EventEmitter {
});
continue;
}
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
attempted += 1;
this._rotLog('suspect-reject-alt', {
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id
@@ -1282,6 +1291,8 @@ class UploadManager extends EventEmitter {
async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, coordinateAccountFailure = true, jobId = task.jobId) {
while (true) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
const context = await this._createRecoveryContext(task);
this._throwIfUploadStartBlocked(signal);
let retryAdmission = false;
@@ -1350,6 +1361,13 @@ class UploadManager extends EventEmitter {
this._beginSuspectResolution(task.hoster, jobId);
throw lateMemoSuspect;
}
if (!this.uploadScheduleGate.evaluate().allowed) {
releaseSlots();
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
retryAdmission = true;
return null;
}
try {
this._throwIfUploadStartBlocked(signal);
return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context);
@@ -1393,6 +1411,10 @@ class UploadManager extends EventEmitter {
}
}
_waitForUploadSchedule(signal) {
return this.uploadScheduleGate.wait(signal, () => this._throwIfUploadStartBlocked(signal));
}
_createSuspectMemoError(task, fileProbe, fileSize) {
if (!fileProbe || fileProbe.isVideoLike !== true || !task.accountId) return null;
if (!this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) return null;
@@ -1864,6 +1886,7 @@ class UploadManager extends EventEmitter {
finishAfterActive() {
this.stopAfterActive = true;
this.uploadScheduleGate.wake();
}
cancel() {
+167
View File
@@ -0,0 +1,167 @@
;(function initUploadSchedule(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.UploadSchedule = api;
})(typeof window !== 'undefined' ? window : globalThis, function createUploadSchedule() {
const WEEKDAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 0]);
const DEFAULT_UPLOAD_SCHEDULE = Object.freeze({
enabled: false,
weekdays: Object.freeze([...WEEKDAY_ORDER]),
start: '00:00',
end: '23:59'
});
function normalizeTime(value) {
const text = typeof value === 'string' ? value.trim() : '';
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : '';
}
function timeMinutes(value) {
const normalized = normalizeTime(value);
if (!normalized) return null;
const [hours, minutes] = normalized.split(':').map(Number);
return hours * 60 + minutes;
}
function normalizeUploadSchedule(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const rawWeekdays = Array.isArray(source.weekdays) ? source.weekdays : DEFAULT_UPLOAD_SCHEDULE.weekdays;
const selected = new Set(rawWeekdays.map(Number).filter(day => Number.isInteger(day) && day >= 0 && day <= 6));
return {
enabled: source.enabled === true,
weekdays: WEEKDAY_ORDER.filter(day => selected.has(day)),
start: normalizeTime(source.start ?? DEFAULT_UPLOAD_SCHEDULE.start),
end: normalizeTime(source.end ?? DEFAULT_UPLOAD_SCHEDULE.end)
};
}
function scheduleValidity(schedule) {
const startMinutes = timeMinutes(schedule.start);
const endMinutes = timeMinutes(schedule.end);
if (schedule.weekdays.length === 0) return { valid: false, reason: 'weekdays', startMinutes, endMinutes };
if (startMinutes === null || endMinutes === null) return { valid: false, reason: 'time', startMinutes, endMinutes };
if (startMinutes === endMinutes) return { valid: false, reason: 'equal-times', startMinutes, endMinutes };
return { valid: true, reason: null, startMinutes, endMinutes };
}
function nextStartDate(schedule, now, startMinutes) {
const selected = new Set(schedule.weekdays);
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, 0, 0, 0, 0);
if (!selected.has(candidate.getDay())) continue;
candidate.setMinutes(startMinutes);
if (candidate.getTime() > now.getTime()) return candidate;
}
return null;
}
function evaluateUploadSchedule(value, now = new Date()) {
const schedule = normalizeUploadSchedule(value);
const current = now instanceof Date ? new Date(now.getTime()) : new Date(now);
if (Number.isNaN(current.getTime())) throw new TypeError('Invalid schedule evaluation date');
if (!schedule.enabled) {
return { schedule, enabled: false, valid: true, allowed: true, reason: null, nextStart: null };
}
const validity = scheduleValidity(schedule);
if (!validity.valid) {
return { schedule, enabled: true, valid: false, allowed: false, reason: validity.reason, nextStart: null };
}
const currentMinutes = current.getHours() * 60 + current.getMinutes();
const currentDay = current.getDay();
const selected = new Set(schedule.weekdays);
const { startMinutes, endMinutes } = validity;
const overnight = startMinutes > endMinutes;
const previousDay = (currentDay + 6) % 7;
const allowed = overnight
? (selected.has(currentDay) && currentMinutes >= startMinutes) || (selected.has(previousDay) && currentMinutes < endMinutes)
: selected.has(currentDay) && currentMinutes >= startMinutes && currentMinutes < endMinutes;
return {
schedule,
enabled: true,
valid: true,
allowed,
reason: allowed ? null : 'closed',
nextStart: allowed ? null : nextStartDate(schedule, current, startMinutes)
};
}
function createAbortError() {
const error = new Error('Aborted');
error.name = 'AbortError';
return error;
}
function createUploadScheduleGate(initial, options = {}) {
const now = typeof options.now === 'function' ? options.now : () => new Date();
const setTimer = typeof options.setTimeout === 'function' ? options.setTimeout : setTimeout;
const clearTimer = typeof options.clearTimeout === 'function' ? options.clearTimeout : clearTimeout;
let schedule = normalizeUploadSchedule(initial);
let disposed = false;
const waiters = new Set();
const wake = () => {
for (const waiter of [...waiters]) waiter();
};
const waitForWake = (state, signal) => new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const finish = (error) => {
if (settled) return;
settled = true;
if (timer !== null) clearTimer(timer);
waiters.delete(onWake);
signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve();
};
const onWake = () => finish();
const onAbort = () => finish(createAbortError());
if (disposed || signal?.aborted) {
finish(createAbortError());
return;
}
waiters.add(onWake);
signal?.addEventListener('abort', onAbort, { once: true });
if (state.nextStart) {
const delay = Math.max(1, Math.min(2147483647, state.nextStart.getTime() - now().getTime()));
timer = setTimer(onWake, delay);
}
});
return Object.freeze({
evaluate() {
return evaluateUploadSchedule(schedule, now());
},
update(value) {
schedule = normalizeUploadSchedule(value);
wake();
return this.evaluate();
},
async wait(signal, check) {
while (true) {
if (typeof check === 'function') check();
if (disposed || signal?.aborted) throw createAbortError();
const state = evaluateUploadSchedule(schedule, now());
if (state.allowed) return state;
await waitForWake(state, signal);
}
},
wake,
dispose() {
disposed = true;
wake();
},
get schedule() {
return normalizeUploadSchedule(schedule);
}
});
}
return {
DEFAULT_UPLOAD_SCHEDULE,
normalizeUploadSchedule,
evaluateUploadSchedule,
createUploadScheduleGate
};
});