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