diff --git a/lib/config-store.js b/lib/config-store.js
index 901ff97..32c35cd 100644
--- a/lib/config-store.js
+++ b/lib/config-store.js
@@ -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);
}
diff --git a/lib/upload-manager.js b/lib/upload-manager.js
index 88d140e..4496ec1 100644
--- a/lib/upload-manager.js
+++ b/lib/upload-manager.js
@@ -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() {
diff --git a/lib/upload-schedule.js b/lib/upload-schedule.js
new file mode 100644
index 0000000..08040c4
--- /dev/null
+++ b/lib/upload-schedule.js
@@ -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
+};
+});
diff --git a/renderer/app.js b/renderer/app.js
index 9f701ef..fbeefbf 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -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.')}
Unbeaufsichtigter Betrieb
@@ -4997,6 +5055,31 @@ function renderSettings() {
Minuten · jede weitere Runde wartet entsprechend länger
+ Upload-Zeitfenster Inaktiv
+
Ordnerüberwachung ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}
@@ -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),
diff --git a/renderer/i18n.js b/renderer/i18n.js
index e65e7ad..f656434 100644
--- a/renderer/i18n.js
+++ b/renderer/i18n.js
@@ -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'],
diff --git a/renderer/index.html b/renderer/index.html
index b61ba5c..5bedcc7 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -721,6 +721,7 @@
+
diff --git a/renderer/styles.css b/renderer/styles.css
index 7ec495d..eebf249 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -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;
}
diff --git a/tests/config-store.test.js b/tests/config-store.test.js
index 9f19f1a..01fd500 100644
--- a/tests/config-store.test.js
+++ b/tests/config-store.test.js
@@ -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 } });
diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js
index 028c7b2..700e69a 100644
--- a/tests/ui-smoke.js
+++ b/tests/ui-smoke.js
@@ -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"; })()');
diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js
index 77d8655..e0058ef 100644
--- a/tests/upload-manager.test.js
+++ b/tests/upload-manager.test.js
@@ -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;
diff --git a/tests/upload-schedule.test.js b/tests/upload-schedule.test.js
new file mode 100644
index 0000000..67da9a8
--- /dev/null
+++ b/tests/upload-schedule.test.js
@@ -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);
+});