Compare commits
3 Commits
221eb55380
...
cafe777377
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafe777377 | ||
|
|
33364168f1 | ||
|
|
05ad08433c |
@ -63,6 +63,7 @@ const DEFAULTS = {
|
||||
webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone
|
||||
autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end
|
||||
autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay)
|
||||
historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history
|
||||
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
|
||||
// would seed logMode='single' for every load, which would beat (and silently
|
||||
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
|
||||
@ -102,6 +103,67 @@ const DEFAULTS = {
|
||||
history: []
|
||||
};
|
||||
|
||||
const HISTORY_RETENTION_OPTIONS = [
|
||||
{ value: 'all', label: 'Alles behalten' },
|
||||
{ value: '7d', label: 'Letzte 7 Tage' },
|
||||
{ value: '30d', label: 'Letzte 30 Tage' },
|
||||
{ value: '90d', label: 'Letzte 90 Tage' },
|
||||
{ value: '1000', label: 'Letzte 1000 Uploads' },
|
||||
{ value: '100', label: 'Letzte 100 Uploads' }
|
||||
];
|
||||
|
||||
function batchTimestampMs(batch) {
|
||||
const raw = batch && batch.timestamp;
|
||||
if (raw === null || raw === undefined || raw === '') return null;
|
||||
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function batchRowCount(batch) {
|
||||
let n = 0;
|
||||
const files = (batch && batch.files) || [];
|
||||
for (const file of files) {
|
||||
for (const result of (file.results || [])) {
|
||||
if (result.status === 'aborted' || result.status === 'error') continue;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countHistoryRows(history) {
|
||||
let n = 0;
|
||||
for (const batch of (history || [])) n += batchRowCount(batch);
|
||||
return n;
|
||||
}
|
||||
|
||||
function applyHistoryRetention(history, retention, nowMs) {
|
||||
if (!Array.isArray(history) || history.length === 0) return history;
|
||||
const policy = String(retention || 'all');
|
||||
if (policy === 'all') return history;
|
||||
|
||||
if (/^\d+d$/.test(policy)) {
|
||||
const days = parseInt(policy, 10);
|
||||
if (!Number.isFinite(days) || days <= 0) return history;
|
||||
const cutoff = nowMs - days * 86400000;
|
||||
return history.filter(b => {
|
||||
const ts = batchTimestampMs(b);
|
||||
return ts === null || ts >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
const maxRows = parseInt(policy, 10);
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return history;
|
||||
const keptReversed = [];
|
||||
let acc = 0;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
keptReversed.push(history[i]);
|
||||
acc += batchRowCount(history[i]);
|
||||
if (acc >= maxRows) break;
|
||||
}
|
||||
return keptReversed.reverse();
|
||||
}
|
||||
|
||||
class ConfigStore {
|
||||
constructor(app) {
|
||||
const dir = app && app.isPackaged
|
||||
@ -301,10 +363,32 @@ class ConfigStore {
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.history.push(entry);
|
||||
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
|
||||
config.history = applyHistoryRetention(config.history, retention, Date.now());
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
});
|
||||
}
|
||||
|
||||
pruneHistory(retention, opts = {}) {
|
||||
const dryRun = !!opts.dryRun;
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
const beforeBatches = config.history.length;
|
||||
const beforeRows = countHistoryRows(config.history);
|
||||
const pruned = applyHistoryRetention(config.history, retention, Date.now());
|
||||
const result = {
|
||||
removedBatches: beforeBatches - pruned.length,
|
||||
removedRows: beforeRows - countHistoryRows(pruned),
|
||||
keptBatches: pruned.length,
|
||||
keptRows: countHistoryRows(pruned)
|
||||
};
|
||||
if (dryRun) return result;
|
||||
config.history = pruned;
|
||||
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
|
||||
return this._atomicWrite(this._serializeForDisk(config)).then(() => result);
|
||||
});
|
||||
}
|
||||
|
||||
clearHistory() {
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
@ -318,3 +402,6 @@ module.exports = ConfigStore;
|
||||
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
|
||||
module.exports.HOSTER_NAMES = HOSTER_NAMES;
|
||||
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;
|
||||
module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS;
|
||||
module.exports.applyHistoryRetention = applyHistoryRetention;
|
||||
module.exports.countHistoryRows = countHistoryRows;
|
||||
|
||||
6
main.js
6
main.js
@ -1297,6 +1297,12 @@ ipcMain.handle('get-history', () => {
|
||||
return configStore.loadHistory();
|
||||
});
|
||||
|
||||
ipcMain.handle('prune-history', async (_event, payload) => {
|
||||
const retention = payload && payload.retention;
|
||||
const dryRun = !!(payload && payload.dryRun);
|
||||
return configStore.pruneHistory(retention, { dryRun });
|
||||
});
|
||||
|
||||
ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) => {
|
||||
const safeName = String(defaultName || `export-${new Date().toISOString().slice(0, 10)}.txt`);
|
||||
const safeFilters = Array.isArray(filters) && filters.length
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.65",
|
||||
"version": "3.3.67",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
getHistory: () => ipcRenderer.invoke('get-history'),
|
||||
clearHistory: () => ipcRenderer.invoke('clear-history'),
|
||||
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
|
||||
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
|
||||
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
|
||||
|
||||
|
||||
@ -3485,12 +3485,15 @@ function renderAccounts() {
|
||||
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
|
||||
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
|
||||
|
||||
const footer = document.getElementById('accountsListFooter');
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="accounts-empty">
|
||||
<p>Keine Accounts vorhanden</p>
|
||||
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
|
||||
</div>`;
|
||||
if (footer) footer.style.display = 'none';
|
||||
if (!_accountListenersBound) bindAccountListeners(container);
|
||||
return;
|
||||
}
|
||||
@ -3509,6 +3512,9 @@ function renderAccounts() {
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
if (footer) footer.style.display = '';
|
||||
_updateToggleAllAccountsBtn();
|
||||
|
||||
if (!_accountListenersBound) bindAccountListeners(container);
|
||||
}
|
||||
|
||||
@ -3578,6 +3584,35 @@ function _hosterLifetimeStat(name) {
|
||||
}
|
||||
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
|
||||
|
||||
function _allAccountGroupsOpen() {
|
||||
const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body');
|
||||
if (!bodies.length) return false;
|
||||
for (const b of bodies) if (b.style.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function _setAllAccountGroupsOpen(open) {
|
||||
const groups = document.querySelectorAll('#accountsList .account-hoster-group');
|
||||
groups.forEach(group => {
|
||||
const name = group.dataset.hosterGroup;
|
||||
const body = group.querySelector('.account-hoster-group-body');
|
||||
const arrow = group.querySelector('.panel-arrow');
|
||||
if (body) body.style.display = open ? '' : 'none';
|
||||
if (arrow) arrow.innerHTML = open ? '▼' : '▶';
|
||||
if (name) {
|
||||
const summary = _summarizeHosterGroup(config.hosters[name] || []);
|
||||
_hosterGroupOpenMemory.set(name, { state: open ? 'open' : 'closed', errorsAtClose: summary.error });
|
||||
}
|
||||
});
|
||||
_updateToggleAllAccountsBtn();
|
||||
}
|
||||
|
||||
function _updateToggleAllAccountsBtn() {
|
||||
const btn = document.getElementById('toggleAllAccountsBtn');
|
||||
if (!btn) return;
|
||||
btn.textContent = _allAccountGroupsOpen() ? 'Alle einklappen' : 'Alle ausklappen';
|
||||
}
|
||||
|
||||
// Single set of delegated listeners on the accounts container. Bound once on
|
||||
// the first render and reused for every subsequent in-place update / card
|
||||
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
|
||||
@ -4122,6 +4157,8 @@ async function loadHistory() {
|
||||
const history = await window.api.getHistory();
|
||||
window._historyForStats = history || [];
|
||||
_invalidateHosterLifetimeCache();
|
||||
const retSel = document.getElementById('historyRetentionSelect');
|
||||
if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
|
||||
const container = document.getElementById('historyContainer');
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
@ -4304,13 +4341,29 @@ function renderRecentUploadsPanel() {
|
||||
if (!wasAppendOnly) updateRecentSortHeaders();
|
||||
}
|
||||
|
||||
const HISTORY_RENDER_CAP = 2000;
|
||||
|
||||
function renderHistoryTable(container) {
|
||||
if (!container || !historyRowsData.length) {
|
||||
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||||
const emptyNotice = document.getElementById('historyCapNotice');
|
||||
if (emptyNotice) emptyNotice.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = sortHistoryRows(historyRowsData);
|
||||
const total = historyRowsData.length;
|
||||
const working = total > HISTORY_RENDER_CAP ? historyRowsData.slice(-HISTORY_RENDER_CAP) : historyRowsData;
|
||||
const notice = document.getElementById('historyCapNotice');
|
||||
if (notice) {
|
||||
if (total > HISTORY_RENDER_CAP) {
|
||||
notice.style.display = '';
|
||||
notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`;
|
||||
} else {
|
||||
notice.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const rows = sortHistoryRows(working);
|
||||
const headerCell = (key, label) => {
|
||||
const active = historySortState.key === key;
|
||||
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
||||
@ -4466,6 +4519,7 @@ function setupListeners() {
|
||||
document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down'));
|
||||
document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom'));
|
||||
document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual'));
|
||||
document.getElementById('toggleAllAccountsBtn').addEventListener('click', () => _setAllAccountGroupsOpen(!_allAccountGroupsOpen()));
|
||||
document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks);
|
||||
document.getElementById('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles);
|
||||
document.getElementById('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles);
|
||||
@ -4498,6 +4552,29 @@ function setupListeners() {
|
||||
});
|
||||
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
|
||||
|
||||
const historyRetentionSelect = document.getElementById('historyRetentionSelect');
|
||||
if (historyRetentionSelect) {
|
||||
historyRetentionSelect.addEventListener('change', async () => {
|
||||
const value = historyRetentionSelect.value;
|
||||
const prev = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
|
||||
if (value !== 'all') {
|
||||
const preview = await window.api.pruneHistory(value, { dryRun: true });
|
||||
if (preview && preview.removedRows > 0) {
|
||||
const ok = confirm(`${preview.removedRows.toLocaleString('de-DE')} Verlaufseinträge werden dauerhaft entfernt.\n\nFortfahren?`);
|
||||
if (!ok) { historyRetentionSelect.value = prev; return; }
|
||||
}
|
||||
}
|
||||
const globalSettings = { ...(config.globalSettings || {}), historyRetention: value };
|
||||
config.globalSettings = globalSettings;
|
||||
await window.api.saveGlobalSettings(globalSettings).catch(() => {});
|
||||
if (value !== 'all') {
|
||||
const res = await window.api.pruneHistory(value);
|
||||
if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString('de-DE')} entfernt`);
|
||||
}
|
||||
loadHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto health check toggle
|
||||
const autoToggle = document.getElementById('autoHealthCheckToggle');
|
||||
if (autoToggle) {
|
||||
|
||||
@ -178,6 +178,9 @@
|
||||
</div>
|
||||
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
|
||||
<div class="accounts-list" id="accountsList"></div>
|
||||
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -257,11 +260,21 @@
|
||||
<div class="history-container">
|
||||
<div class="history-header">
|
||||
<h2>Upload-Verlauf</h2>
|
||||
<div style="display:flex; gap:8px">
|
||||
<div style="display:flex; gap:8px; align-items:center">
|
||||
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
|
||||
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
||||
<option value="all">Alles behalten</option>
|
||||
<option value="7d">Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="90d">Letzte 90 Tage</option>
|
||||
<option value="1000">Letzte 1000 Uploads</option>
|
||||
<option value="100">Letzte 100 Uploads</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
|
||||
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
||||
<div id="historyContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -793,6 +793,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
.accounts-list { display: grid; gap: 8px; }
|
||||
.accounts-list-footer { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
|
||||
.account-card {
|
||||
display: flex;
|
||||
@ -979,6 +980,17 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
|
||||
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.history-header h2 { font-size: 18px; }
|
||||
.history-retention-label { font-size: 12px; color: var(--text-dim); margin-right: 2px; }
|
||||
.history-retention-select { width: auto; min-width: 150px; padding: 6px 8px; }
|
||||
.history-cap-notice {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.results-table, .history-table {
|
||||
width: 100%;
|
||||
|
||||
84
tests/history-retention.test.js
Normal file
84
tests/history-retention.test.js
Normal file
@ -0,0 +1,84 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store');
|
||||
|
||||
function batch(timestamp, okRows, extras = {}) {
|
||||
const results = [];
|
||||
for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` });
|
||||
if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' });
|
||||
if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' });
|
||||
return { timestamp, files: [{ name: 'clip.mp4', results }] };
|
||||
}
|
||||
|
||||
const DAY = 86400000;
|
||||
|
||||
test('countHistoryRows counts only non-aborted, non-error results', () => {
|
||||
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
|
||||
assert.strictEqual(countHistoryRows(h), 3);
|
||||
});
|
||||
|
||||
test('retention "all" returns the array unchanged', () => {
|
||||
const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)];
|
||||
assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h);
|
||||
});
|
||||
|
||||
test('count policy keeps newest whole batches up to the row target', () => {
|
||||
const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)];
|
||||
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||
assert.strictEqual(pruned.length, 3);
|
||||
assert.strictEqual(countHistoryRows(pruned), 1200);
|
||||
});
|
||||
|
||||
test('count policy drops older batches once target reached (newest first)', () => {
|
||||
const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)];
|
||||
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||
assert.strictEqual(pruned.length, 2);
|
||||
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
|
||||
});
|
||||
|
||||
test('count policy always keeps the newest batch even if it alone exceeds N', () => {
|
||||
const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)];
|
||||
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
|
||||
assert.strictEqual(pruned.length, 1);
|
||||
assert.strictEqual(pruned[0].timestamp, '2026-01-02');
|
||||
});
|
||||
|
||||
test('time policy drops batches older than the cutoff', () => {
|
||||
const now = Date.parse('2026-06-15T00:00:00Z');
|
||||
const h = [
|
||||
batch(new Date(now - 10 * DAY).toISOString(), 5),
|
||||
batch(new Date(now - 3 * DAY).toISOString(), 5),
|
||||
batch(new Date(now - 1 * DAY).toISOString(), 5)
|
||||
];
|
||||
const pruned = applyHistoryRetention(h, '7d', now);
|
||||
assert.strictEqual(pruned.length, 2);
|
||||
});
|
||||
|
||||
test('time policy keeps batches with missing or invalid timestamp', () => {
|
||||
const now = Date.parse('2026-06-15T00:00:00Z');
|
||||
const h = [
|
||||
batch(undefined, 5),
|
||||
batch('not-a-date', 5),
|
||||
batch(new Date(now - 99 * DAY).toISOString(), 5),
|
||||
batch(new Date(now - 1 * DAY).toISOString(), 5)
|
||||
];
|
||||
const pruned = applyHistoryRetention(h, '30d', now);
|
||||
assert.strictEqual(pruned.length, 3);
|
||||
assert.ok(pruned.includes(h[0]));
|
||||
assert.ok(pruned.includes(h[1]));
|
||||
assert.ok(!pruned.includes(h[2]));
|
||||
});
|
||||
|
||||
test('count policy shrinks a realistic 41-batch / >1000-row history', () => {
|
||||
const h = [];
|
||||
for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300));
|
||||
assert.strictEqual(countHistoryRows(h), 41 * 1300);
|
||||
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||
assert.strictEqual(pruned.length, 1);
|
||||
assert.strictEqual(countHistoryRows(pruned), 1300);
|
||||
});
|
||||
|
||||
test('empty history is returned as-is for any policy', () => {
|
||||
assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []);
|
||||
assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user