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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user