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
};
});
+103 -2
View File
@@ -57,6 +57,7 @@ function refreshLocalizedRuntimeUi() {
const historyContainer = document.getElementById('historyContainer');
if (historyContainer && historyRowsData.length) renderHistoryTable(historyContainer);
updateStatusBar();
if (document.getElementById('uploadScheduleEnabledInput')) syncUploadScheduleControls();
const activeRecentTab = document.querySelector('.recent-tab.active');
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
@@ -4787,7 +4788,63 @@ function appendFilenameFilterCondition(condition = {}) {
markSettingsDirty();
}
let uploadScheduleStatusTimer = null;
function readUploadScheduleSettings(fallback = config.globalSettings?.uploadSchedule) {
if (!window.UploadSchedule) return fallback || { enabled: false };
const enabledInput = document.getElementById('uploadScheduleEnabledInput');
if (!enabledInput) return window.UploadSchedule.normalizeUploadSchedule(fallback);
return window.UploadSchedule.normalizeUploadSchedule({
enabled: enabledInput.checked,
weekdays: Array.from(document.querySelectorAll('[data-upload-schedule-day]:checked')).map(input => Number(input.value)),
start: document.getElementById('uploadScheduleStartInput')?.value,
end: document.getElementById('uploadScheduleEndInput')?.value
});
}
function syncUploadScheduleControls() {
if (!window.UploadSchedule) return;
const schedule = readUploadScheduleSettings();
const state = window.UploadSchedule.evaluateUploadSchedule(schedule, new Date());
const enabled = schedule.enabled;
document.querySelectorAll('[data-upload-schedule-dependent]').forEach(control => {
control.disabled = !enabled;
});
const status = document.getElementById('uploadScheduleStatus');
const badge = document.getElementById('uploadScheduleStatusBadge');
if (!status || !badge) return;
let text;
let badgeText;
let stateClass = '';
if (!enabled) {
text = localizeUiText('Deaktiviert. Neue Uploads starten sofort.');
badgeText = localizeUiText('Inaktiv');
} else if (!state.valid) {
text = state.reason === 'weekdays'
? localizeUiText('Ungültig. Wähle mindestens einen Wochentag aus.')
: state.reason === 'equal-times'
? localizeUiText('Ungültig. Start und Ende müssen unterschiedlich sein.')
: localizeUiText('Ungültig. Prüfe Start- und Endzeit.');
badgeText = localizeUiText('Ungültig');
stateClass = ' warning';
} else if (state.allowed) {
text = localizeUiText('Geöffnet. Neue Uploads dürfen starten.');
badgeText = localizeUiText('Geöffnet');
stateClass = ' active';
} else {
const nextStart = state.nextStart ? formatDateTime(state.nextStart.toISOString()) : '—';
text = `${localizeUiText('Geschlossen. Nächster erlaubter Start:')} ${nextStart}`;
badgeText = localizeUiText('Geschlossen');
stateClass = ' warning';
}
status.textContent = text;
badge.textContent = badgeText;
badge.className = `panel-status${stateClass}`;
}
function renderSettings() {
clearInterval(uploadScheduleStatusTimer);
uploadScheduleStatusTimer = null;
const container = document.getElementById('settingsHosters');
container.innerHTML = '';
@@ -4796,6 +4853,7 @@ function renderSettings() {
const fm = globalSettings.folderMonitor || {};
const remoteSettings = globalSettings.remote || {};
const filenameFilter = window.FilenameFilter.normalizeFilenameFilter(globalSettings.filenameFilter);
const uploadSchedule = window.UploadSchedule.normalizeUploadSchedule(globalSettings.uploadSchedule);
const filenameFilterConditions = filenameFilter.conditions.length > 0
? filenameFilter.conditions
: [{ operator: 'contains', value: '' }];
@@ -4803,7 +4861,7 @@ function renderSettings() {
const pageDefinitions = [
{ id: 'allgemein', label: 'Allgemein', search: 'fenster window vordergrund foreground always on top drop target oberfläche interface updates update aktualisierung version language sprache' },
{ id: 'uploads', label: 'Uploads', search: 'upload queue warteschlange waiting fertig completed completion abschluss entfernen remove parallel geschwindigkeit speed limit fortsetzen resume wiederherstellen restore hoster dateiname filename filter enthält contains ausschließen exclude' },
{ id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' },
{ id: 'automatik', label: 'Automatik', search: 'automatisch automation automatic retry wiederholen zeitfenster schedule wochentage weekdays start ende ordner folder monitor überwachen watch dateierweiterungen extensions unterordner subfolders duplikate duplicates' },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', search: 'benachrichtigungen notifications webhook discord meldung message ping erwähnung mention batch fertig completed' },
{ id: 'logs', label: 'Logs & Support', search: 'log logs protokoll logging debug verbose diagnose diagnostics support paket package datei file ordner folder' },
{ id: 'remote', label: 'Fernsteuerung', search: 'remote control fernsteuerung server input port api token verbindung connection client' },
@@ -4981,7 +5039,7 @@ function renderSettings() {
`;
pages.automatik.innerHTML = `
${pageHeader('Automatik', 'Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.')}
${pageHeader('Automatik', 'Wiederholungen, Upload-Zeitfenster und überwachte Ordner für unbeaufsichtigte Uploads.')}
<div class="settings-section-label">Unbeaufsichtigter Betrieb</div>
<div class="settings-row automation-retry-row">
<label for="autoRetryRoundsInput">Automatische Wiederholungsrunden</label>
@@ -4997,6 +5055,31 @@ function renderSettings() {
<span class="hint">Minuten · jede weitere Runde wartet entsprechend länger</span>
</div>
</div>
<div class="settings-section-label">Upload-Zeitfenster <span class="panel-status" id="uploadScheduleStatusBadge">Inaktiv</span></div>
<div class="upload-schedule-panel">
<div class="settings-option upload-schedule-toggle">
<div class="settings-option-copy">
<label for="uploadScheduleEnabledInput">Neue Uploads nur im Zeitfenster starten</label>
<span class="settings-option-description">Laufende Uploads dürfen fertig werden. Wartende Uploads starten automatisch bei der nächsten Öffnung.</span>
</div>
<input type="checkbox" class="settings-autosave" id="uploadScheduleEnabledInput" data-upload-schedule-control ${uploadSchedule.enabled ? 'checked' : ''}>
</div>
<div class="upload-schedule-days" role="group" aria-label="Erlaubte Wochentage">
<label><input type="checkbox" class="settings-autosave" value="1" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(1) ? 'checked' : ''}><span>Mo</span></label>
<label><input type="checkbox" class="settings-autosave" value="2" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(2) ? 'checked' : ''}><span>Di</span></label>
<label><input type="checkbox" class="settings-autosave" value="3" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(3) ? 'checked' : ''}><span>Mi</span></label>
<label><input type="checkbox" class="settings-autosave" value="4" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(4) ? 'checked' : ''}><span>Do</span></label>
<label><input type="checkbox" class="settings-autosave" value="5" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(5) ? 'checked' : ''}><span>Fr</span></label>
<label><input type="checkbox" class="settings-autosave" value="6" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(6) ? 'checked' : ''}><span>Sa</span></label>
<label><input type="checkbox" class="settings-autosave" value="0" data-upload-schedule-day data-upload-schedule-control data-upload-schedule-dependent ${uploadSchedule.weekdays.includes(0) ? 'checked' : ''}><span>So</span></label>
</div>
<div class="upload-schedule-times">
<label for="uploadScheduleStartInput"><span>Start</span><input type="time" class="hs-input settings-autosave" id="uploadScheduleStartInput" data-upload-schedule-control data-upload-schedule-dependent value="${escapeAttr(uploadSchedule.start)}"></label>
<label for="uploadScheduleEndInput"><span>Ende</span><input type="time" class="hs-input settings-autosave" id="uploadScheduleEndInput" data-upload-schedule-control data-upload-schedule-dependent value="${escapeAttr(uploadSchedule.end)}"></label>
<span class="hint">Lokale Systemzeit · Zeitfenster über Mitternacht werden unterstützt</span>
</div>
<p class="upload-schedule-status" id="uploadScheduleStatus" aria-live="polite"></p>
</div>
<div class="settings-section-label">Ordnerüberwachung <span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span></div>
<div class="settings-row">
<label>Ordnerpfad</label>
@@ -5472,6 +5555,12 @@ function renderSettings() {
document.getElementById('addFilenameFilterConditionBtn')?.addEventListener('click', () => appendFilenameFilterCondition());
document.getElementById('filenameFilterEnabledInput')?.addEventListener('change', syncFilenameFilterControls);
syncFilenameFilterControls();
container.querySelectorAll('[data-upload-schedule-control]').forEach(control => {
const eventName = control.type === 'time' ? 'input' : 'change';
control.addEventListener(eventName, syncUploadScheduleControls);
});
syncUploadScheduleControls();
uploadScheduleStatusTimer = setInterval(syncUploadScheduleControls, 30000);
_syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
@@ -5479,6 +5568,7 @@ function renderSettings() {
if (input.id === 'languageInput') {
setUiLanguage(input.value);
syncLanguagePicker(input.value);
syncUploadScheduleControls();
}
if (input.id === 'deleteSourceAfterSuccessfulUploadInput' && input.checked) {
const confirmed = await showAppConfirm({
@@ -5590,6 +5680,16 @@ async function performSaveSettings(options = {}) {
const cur = config.globalSettings || {};
const curFm = cur.folderMonitor || {};
const curRemote = cur.remote || {};
const uploadSchedule = readUploadScheduleSettings(cur.uploadSchedule);
const uploadScheduleState = window.UploadSchedule.evaluateUploadSchedule(uploadSchedule, new Date());
if (uploadSchedule.enabled && !uploadScheduleState.valid) {
const message = uploadScheduleState.reason === 'weekdays'
? 'Wähle mindestens einen Wochentag für das Upload-Zeitfenster aus.'
: uploadScheduleState.reason === 'equal-times'
? 'Start und Ende des Upload-Zeitfensters müssen unterschiedlich sein.'
: 'Prüfe Start und Ende des Upload-Zeitfensters.';
throw new Error(localizeUiText(message));
}
const elTxt = (id, fb) => { const el = document.getElementById(id); return el ? el.value : fb; };
const elChk = (id, fb) => { const el = document.getElementById(id); return el ? !!el.checked : fb; };
const elInt = (id, curVal, dflt, lo, hi) => {
@@ -5630,6 +5730,7 @@ async function performSaveSettings(options = {}) {
webhookMention: elTxt('webhookMentionInput', cur.webhookMention || '').trim(),
autoRetryRounds: elInt('autoRetryRoundsInput', cur.autoRetryRounds ?? 0, 0, 0, 5),
autoRetryDelayMin: elInt('autoRetryDelayMinInput', cur.autoRetryDelayMin ?? 5, 5, 1, 120),
uploadSchedule,
folderMonitor: {
...curFm,
enabled: elChk('fmEnabledInput', !!curFm.enabled),
+27
View File
@@ -258,11 +258,38 @@
['wie Wiederholungen, Geschwindigkeit, Parallelität, Dateigröße und Logging findest du im', 'such as retries, speed, parallelism, file size, and logging are available in'],
['Accounts-Tab.', 'the Accounts tab.'],
['Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.', 'Retries and watched folders for unattended uploads.'],
['Wiederholungen, Upload-Zeitfenster und überwachte Ordner für unbeaufsichtigte Uploads.', 'Retries, upload schedules, and watched folders for unattended uploads.'],
['Unbeaufsichtigter Betrieb', 'Unattended operation'],
['Automatische Wiederholungsrunden', 'Automatic retry rounds'],
['0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht.', '0 = off. After a batch ends, transient errors (network or host issues) are retried automatically for up to N rounds.'],
['Wartezeit zwischen Runden', 'Delay between rounds'],
['Minuten · jede weitere Runde wartet entsprechend länger', 'Minutes · each additional round waits proportionally longer'],
['Upload-Zeitfenster', 'Upload schedule'],
['Neue Uploads nur im Zeitfenster starten', 'Start new uploads only during the schedule'],
['Laufende Uploads dürfen fertig werden. Wartende Uploads starten automatisch bei der nächsten Öffnung.', 'Active uploads may finish. Waiting uploads start automatically when the schedule opens again.'],
['Erlaubte Wochentage', 'Allowed weekdays'],
['Mo', 'Mon'],
['Di', 'Tue'],
['Mi', 'Wed'],
['Do', 'Thu'],
['Fr', 'Fri'],
['Sa', 'Sat'],
['So', 'Sun'],
['Start', 'Start'],
['Ende', 'End'],
['Lokale Systemzeit · Zeitfenster über Mitternacht werden unterstützt', 'Local system time · overnight schedules are supported'],
['Deaktiviert. Neue Uploads starten sofort.', 'Disabled. New uploads start immediately.'],
['Ungültig. Wähle mindestens einen Wochentag aus.', 'Invalid. Select at least one weekday.'],
['Ungültig. Start und Ende müssen unterschiedlich sein.', 'Invalid. Start and end must be different.'],
['Ungültig. Prüfe Start- und Endzeit.', 'Invalid. Check the start and end time.'],
['Geöffnet. Neue Uploads dürfen starten.', 'Open. New uploads may start.'],
['Geschlossen. Nächster erlaubter Start:', 'Closed. Next allowed start:'],
['Geöffnet', 'Open'],
['Geschlossen', 'Closed'],
['Ungültig', 'Invalid'],
['Wähle mindestens einen Wochentag für das Upload-Zeitfenster aus.', 'Select at least one weekday for the upload schedule.'],
['Start und Ende des Upload-Zeitfensters müssen unterschiedlich sein.', 'The upload schedule start and end must be different.'],
['Prüfe Start und Ende des Upload-Zeitfensters.', 'Check the upload schedule start and end.'],
['Ordnerüberwachung', 'Folder monitoring'],
['Inaktiv', 'Inactive'],
['Ordnerpfad', 'Folder path'],
+1
View File
@@ -721,6 +721,7 @@
<script src="../lib/upload-recovery.js"></script>
<script src="../lib/filename-filter.js"></script>
<script src="../lib/import-preflight.js"></script>
<script src="../lib/upload-schedule.js"></script>
<script src="account-submit.js"></script>
<script src="account-status.js"></script>
<script src="history-status.js"></script>
+59
View File
@@ -1033,6 +1033,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.panel-status { font-size: 10px; padding: 2px 8px; border-radius: 3px; }
.panel-status.active { background: rgba(0, 184, 148, 0.2); color: var(--success); }
.panel-status.inactive { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); }
.panel-status.warning { background: rgba(245, 166, 35, 0.16); color: var(--warning); }
.hoster-panel-body { padding: 0 14px 14px; }
.settings-divider { height: 1px; background: var(--border); margin: 12px 0; }
@@ -1192,6 +1193,61 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
}
.settings-subpage .settings-grid-mini .checkbox-row label { min-width: 0; cursor: pointer; }
.settings-subpage .settings-grid-mini .checkbox-row input[type="checkbox"] { order: 2; width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.upload-schedule-panel {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--bg-input) 62%, transparent);
}
.upload-schedule-panel .settings-option { margin: 0; }
.upload-schedule-days {
display: grid;
grid-template-columns: repeat(7, minmax(48px, 1fr));
gap: 6px;
}
.upload-schedule-days label { min-width: 0; cursor: pointer; }
.upload-schedule-days input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.upload-schedule-days span {
display: grid;
place-items: center;
min-height: 34px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-input);
color: var(--text-dim);
font-size: 12px;
font-weight: 700;
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
}
.upload-schedule-days input:checked + span {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 24%, var(--bg-input));
color: var(--text);
}
.upload-schedule-days input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.upload-schedule-days input:disabled + span { opacity: 0.42; cursor: default; }
.upload-schedule-times {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(220px, 1.5fr);
gap: 10px;
align-items: end;
}
.upload-schedule-times label { display: grid; gap: 6px; color: var(--text); font-size: 12px; font-weight: 600; }
.upload-schedule-times .hs-input { width: 100%; max-width: none; }
.upload-schedule-times .hint { align-self: center; line-height: 1.45; }
.upload-schedule-status {
min-height: 18px;
margin: 0;
color: var(--text-dim);
font-size: 11px;
line-height: 1.45;
}
.settings-hoster-pointer {
margin-top: 16px;
padding: 11px 13px;
@@ -1873,6 +1929,9 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.settings-navigation { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-content { overflow: visible; }
.settings-grid-mini { grid-template-columns: 1fr; }
.upload-schedule-days { grid-template-columns: repeat(4, minmax(48px, 1fr)); }
.upload-schedule-times { grid-template-columns: 1fr 1fr; }
.upload-schedule-times .hint { grid-column: 1 / -1; }
.online-backup-key-row {
grid-template-columns: 1fr;
}
+36
View File
@@ -126,6 +126,12 @@ describe('ConfigStore', () => {
matchMode: 'all',
conditions: []
});
assert.deepEqual(config.globalSettings.uploadSchedule, {
enabled: false,
weekdays: [1, 2, 3, 4, 5, 6, 0],
start: '00:00',
end: '23:59'
});
assert.deepEqual(config.history, []);
});
@@ -383,6 +389,36 @@ describe('ConfigStore', () => {
assert.equal(config.globalSettings.logFilePath, '');
});
it('normalizes upload schedules across load and save boundaries', async () => {
fs.writeFileSync(store.filePath, JSON.stringify({
globalSettings: {
uploadSchedule: { enabled: true, weekdays: [0, 1, 1, 9], start: ' 22:00 ', end: '06:00' }
}
}), 'utf-8');
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
enabled: true,
weekdays: [1, 0],
start: '22:00',
end: '06:00'
});
const current = store.load();
await store.save({
globalSettings: {
...current.globalSettings,
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '08:00' }
}
});
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
enabled: true,
weekdays: [],
start: '08:00',
end: '08:00'
});
});
it('concurrent saves preserve both sections', async () => {
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
+45 -1
View File
@@ -238,7 +238,7 @@ setTimeout(async () => {
await captureVisual('00-language-picker.png');
await wc.executeJavaScript('document.getElementById("upload-tab").click()');
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log', 'mp4,mkv,avi']);
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Start', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log', 'mp4,mkv,avi']);
const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log']);
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !neutralUiPathBasenames.has(path.basename(value)) && !value.includes('Multi-Hoster-Uploader'));
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
@@ -1723,6 +1723,50 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()');
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
const uploadScheduleSettings = await wc.executeJavaScript(\`(async () => {
document.querySelector('[data-settings-page="automatik"]')?.click();
const toggle = document.getElementById('uploadScheduleEnabledInput');
const start = document.getElementById('uploadScheduleStartInput');
const end = document.getElementById('uploadScheduleEndInput');
const days = [...document.querySelectorAll('[data-upload-schedule-day]')];
const panel = document.querySelector('.upload-schedule-panel');
const initial = {
present: Boolean(toggle && start && end && days.length === 7),
dependentDisabled: days.every(input => input.disabled) && start.disabled && end.disabled,
contained: panel.scrollWidth <= panel.clientWidth + 1
};
toggle.checked = true;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
days.forEach(input => { input.checked = false; });
start.value = '22:00';
end.value = '06:00';
syncUploadScheduleControls();
const invalid = {
status: document.getElementById('uploadScheduleStatus')?.textContent.trim(),
badge: document.getElementById('uploadScheduleStatusBadge')?.textContent.trim(),
saveRejected: await performSaveSettings().then(() => false, () => true)
};
days.find(input => input.value === '1').checked = true;
syncUploadScheduleControls();
await saveSettings({ feedbackText: 'Gespeichert' });
const saved = (await window.api.getGlobalSettings()).uploadSchedule;
setUiLanguage('en');
syncUploadScheduleControls();
const english = {
heading: document.getElementById('uploadScheduleEnabledInput')?.closest('.settings-option')?.querySelector('label')?.textContent.trim(),
badge: document.getElementById('uploadScheduleStatusBadge')?.textContent.trim(),
status: document.getElementById('uploadScheduleStatus')?.textContent.trim()
};
setUiLanguage('de');
toggle.checked = false;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
await saveSettings({ feedbackText: 'Gespeichert' });
return { initial, invalid, saved, english, restored: (await window.api.getGlobalSettings()).uploadSchedule.enabled === false };
})()\`);
check('Automation exposes a contained seven-day upload schedule with dependent controls disabled by default', uploadScheduleSettings.initial.present && uploadScheduleSettings.initial.dependentDisabled && uploadScheduleSettings.initial.contained);
check('Invalid upload schedules are explained and rejected before persistence', uploadScheduleSettings.invalid.badge === 'Ungültig' && uploadScheduleSettings.invalid.status.includes('mindestens einen Wochentag') && uploadScheduleSettings.invalid.saveRejected);
check('Valid overnight schedules persist with the selected originating weekday', uploadScheduleSettings.saved.enabled === true && uploadScheduleSettings.saved.start === '22:00' && uploadScheduleSettings.saved.end === '06:00' && uploadScheduleSettings.saved.weekdays.join(',') === '1');
check('Upload schedule status and controls switch fully to English without restart', uploadScheduleSettings.english.heading === 'Start new uploads only during the schedule' && ['Open', 'Closed'].includes(uploadScheduleSettings.english.badge) && /^(Open|Closed)\./.test(uploadScheduleSettings.english.status) && uploadScheduleSettings.restored);
await captureVisual('03-automation.png');
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 16, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
+113
View File
@@ -70,6 +70,119 @@ describe('UploadManager', () => {
assert.ok(events.length > 0, 'should emit at least one progress event');
});
it('waits outside the upload schedule and starts on a live settings update without consuming an attempt', async () => {
const mgr = new UploadManager({}, {
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
});
const events = [];
mgr.on('progress', event => events.push(event));
const done = new Promise(resolve => mgr.once('batch-done', resolve));
const batch = mgr.startBatch([{
file: '/test/scheduled.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
jobId: 'scheduled-job'
}]);
await new Promise(setImmediate);
await new Promise(setImmediate);
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(events.some(event => event.status === 'getting-server' || event.status === 'uploading'), false);
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
await batch;
const summary = await done;
const result = summary.files[0].results[0];
assert.equal(mockUploadFile.mock.calls.length, 1);
assert.equal(result.status, 'done');
assert.equal(result.attempt, 1);
});
it('cancels a schedule waiter without starting transport or recording a failure', async () => {
const mgr = new UploadManager({}, {
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
});
const done = new Promise(resolve => mgr.once('batch-done', resolve));
const batch = mgr.startBatch([{
file: '/test/scheduled-cancel.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
jobId: 'scheduled-cancel-job'
}]);
await new Promise(setImmediate);
mgr.cancel();
await batch;
const summary = await done;
const result = summary.files[0].results[0];
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(result.status, 'aborted');
assert.equal(result.attempt, 0);
});
it('finishAfterActive wakes a closed schedule waiter without starting transport', async () => {
const mgr = new UploadManager({}, {
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
});
const done = new Promise(resolve => mgr.once('batch-done', resolve));
const batch = mgr.startBatch([{
jobId: 'scheduled-stop',
file: '/test/scheduled-stop.mp4',
hoster: 'doodstream.com',
apiKey: 'key1'
}]);
await new Promise(resolve => setImmediate(resolve));
mgr.finishAfterActive();
await batch;
const summary = await done;
const result = summary.files[0].results[0];
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(result.status, 'aborted');
assert.equal(result.attempt, 0);
});
it('releases an acquired slot when the schedule closes and re-admits after reopening', async () => {
let releaseFirst;
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
if (filePath === '/test/schedule-first.mp4') {
return new Promise(resolve => { releaseFirst = () => resolve({ download_url: `https://${hoster}/first`, file_code: 'first' }); });
}
return { download_url: `https://${hoster}/second`, file_code: 'second' };
});
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1 }
}, {
uploadSchedule: { enabled: false }
});
const done = new Promise(resolve => mgr.once('batch-done', resolve));
const batch = mgr.startBatch([
{ file: '/test/schedule-first.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-first' },
{ file: '/test/schedule-second.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-second' }
]);
for (let index = 0; index < 50 && typeof releaseFirst !== 'function'; index++) await new Promise(setImmediate);
assert.equal(typeof releaseFirst, 'function');
mgr.updateSettings(null, {
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
});
releaseFirst();
for (let index = 0; index < 10; index++) await new Promise(setImmediate);
assert.equal(mockUploadFile.mock.calls.length, 1);
assert.equal(mgr._getSemaphore('doodstream.com').active, 0);
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
await batch;
const summary = await done;
assert.equal(mockUploadFile.mock.calls.length, 2);
assert.equal(summary.succeeded, 2);
});
it('emits job-settled after releasing job resources', async () => {
const mgr = new UploadManager({});
let settled;
+125
View File
@@ -0,0 +1,125 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
normalizeUploadSchedule,
evaluateUploadSchedule,
createUploadScheduleGate
} = require('../lib/upload-schedule');
function at(iso) {
return new Date(iso);
}
test('normalizes weekdays and valid local times into a stable Monday-first shape', () => {
assert.deepEqual(normalizeUploadSchedule({
enabled: true,
weekdays: [0, 1, 1, 8, '2'],
start: ' 08:15 ',
end: '17:45'
}), {
enabled: true,
weekdays: [1, 2, 0],
start: '08:15',
end: '17:45'
});
});
test('allows a selected daytime window with an inclusive start and exclusive end', () => {
const schedule = { enabled: true, weekdays: [1], start: '08:00', end: '10:00' };
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T08:00:00')).allowed, true);
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T09:59:59')).allowed, true);
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T10:00:00')).allowed, false);
});
test('attributes the after-midnight half of an overnight window to the originating weekday', () => {
const schedule = { enabled: true, weekdays: [1], start: '22:00', end: '06:00' };
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T22:00:00')).allowed, true);
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T05:59:59')).allowed, true);
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T06:00:00')).allowed, false);
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-19T05:00:00')).allowed, false);
});
test('finds the next selected start across the week boundary', () => {
const result = evaluateUploadSchedule(
{ enabled: true, weekdays: [1], start: '08:30', end: '09:30' },
at('2026-08-23T12:00:00')
);
assert.equal(result.allowed, false);
assert.equal(result.nextStart.getDay(), 1);
assert.equal(result.nextStart.getHours(), 8);
assert.equal(result.nextStart.getMinutes(), 30);
assert.equal(result.nextStart.getDate(), 24);
});
test('reports enabled schedules with equal times, missing times, or no weekdays as invalid', () => {
assert.deepEqual(
evaluateUploadSchedule({ enabled: true, weekdays: [1], start: '08:00', end: '08:00' }, at('2026-08-17T08:00:00')).reason,
'equal-times'
);
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [], start: '08:00', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'weekdays');
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [1], start: 'bad', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'time');
});
test('disabled schedules always allow uploads', () => {
const result = evaluateUploadSchedule({ enabled: false, weekdays: [], start: '', end: '' }, at('2026-08-17T08:00:00'));
assert.equal(result.valid, true);
assert.equal(result.allowed, true);
assert.equal(result.nextStart, null);
});
test('gate wakes all waiting jobs when settings are updated', async () => {
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
const first = gate.wait();
const second = gate.wait();
gate.update({ enabled: false });
const results = await Promise.all([first, second]);
assert.equal(results.every(result => result.allowed), true);
});
test('gate rejects a waiting job immediately when its signal is aborted', async () => {
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
const controller = new AbortController();
const waiting = gate.wait(controller.signal);
controller.abort();
await assert.rejects(waiting, error => error?.name === 'AbortError');
});
test('gate rechecks an external stop condition when explicitly woken', async () => {
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
let stopped = false;
const waiting = gate.wait(undefined, () => {
if (!stopped) return;
const error = new Error('Stopped');
error.stopAfterActive = true;
throw error;
});
await Promise.resolve();
stopped = true;
gate.wake();
await assert.rejects(waiting, error => error.stopAfterActive === true);
});
test('gate schedules a wake for the exact next opening', async () => {
let current = at('2026-08-17T07:30:00');
let timerDelay = null;
let timerCallback = null;
const gate = createUploadScheduleGate(
{ enabled: true, weekdays: [1], start: '08:00', end: '09:00' },
{
now: () => current,
setTimeout(callback, delay) {
timerCallback = callback;
timerDelay = delay;
return 1;
},
clearTimeout() {}
}
);
const waiting = gate.wait();
assert.equal(timerDelay, 30 * 60 * 1000);
current = at('2026-08-17T08:00:00');
timerCallback();
assert.equal((await waiting).allowed, true);
});