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
+
+
+
+ + Laufende Uploads dürfen fertig werden. Wartende Uploads starten automatisch bei der nächsten Öffnung. +
+ +
+
+ + + + + + + +
+
+ + + Lokale Systemzeit · Zeitfenster über Mitternacht werden unterstützt +
+

+
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); +});