Compare commits

..

No commits in common. "4af89d7aa31416879dc2b284dc129880742dc385" and "e49f5493fe5fa6478b99673791185793b0e169e3" have entirely different histories.

4 changed files with 38 additions and 129 deletions

19
main.js
View File

@ -300,11 +300,6 @@ function _flushUploadLog() {
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
const target = _resolveUploadLogTarget();
if (!target) { _uploadLogBuffer.length = 0; return; }
// Guard against the file's parent directory having been deleted/moved
// since the cache was filled. mkdirSync(recursive:true) is a no-op when
// the dir already exists; recreates it otherwise. Without this, every
// subsequent flush silently fails with ENOENT and entries are lost.
try { fs.mkdirSync(path.dirname(target.path), { recursive: true }); } catch {}
const chunk = _uploadLogBuffer.join('');
_uploadLogBuffer.length = 0;
_uploadLogWriting = true;
@ -312,18 +307,6 @@ function _flushUploadLog() {
_uploadLogWriting = false;
if (err) {
debugLog(`uploadLog append failed: ${err.message}`);
// Recovery: drop the cached target so the next flush re-resolves
// (could be ENOENT after dir delete, ENOSPC, EBUSY etc.) and
// restore the chunk so we don't lose entries on the retry.
_invalidateUploadLogTargetCache();
_uploadLogBuffer.unshift(chunk);
// Retry on the next event-loop tick rather than tight-looping.
if (!_uploadLogFlushTimer) {
_uploadLogFlushTimer = setTimeout(() => {
_uploadLogFlushTimer = null;
_flushUploadLog();
}, 1000);
}
} else if (target.isFallback && !_uploadLogFallbackWarned) {
_uploadLogFallbackWarned = true;
// Auto-persist the working fallback into the user's config so the
@ -334,7 +317,7 @@ function _flushUploadLog() {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
}
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
if (_uploadLogBuffer.length) setImmediate(_flushUploadLog);
});
}

View File

@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.0",
"version": "3.2.3",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {

View File

@ -1151,14 +1151,6 @@ const _collatorSimple = new Intl.Collator('de');
let _queueSortCache = { sig: '', result: [], jobsRef: null };
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
// Dynamic-key sort throttle: status/speed/progress/size change on every
// progress tick, so a strict per-call sort is O(N log N) per render. Within
// one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's
// slightly out of order — the user can't perceive sub-200ms reorder lag, and
// at 5000 queued jobs this is the difference between smooth and stuttering.
let _dynamicSortCache = { key: '', direction: '', jobsRef: null, result: null, ts: 0 };
const DYNAMIC_SORT_REFRESH_MS = 200;
function sortQueueJobs(jobs) {
const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1;
@ -1167,13 +1159,6 @@ function sortQueueJobs(jobs) {
if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) {
return _queueSortCache.result;
}
// Dynamic-key throttle: same key+direction+array, sorted within the last
// 200ms → reuse. Cleared on user-initiated sort changes (different key).
if (!canCache && _dynamicSortCache.jobsRef === jobs &&
_dynamicSortCache.key === key && _dynamicSortCache.direction === direction &&
_dynamicSortCache.result && (Date.now() - _dynamicSortCache.ts) < DYNAMIC_SORT_REFRESH_MS) {
return _dynamicSortCache.result;
}
const sorted = jobs.slice().sort((a, b) => {
let cmp = 0;
@ -1186,7 +1171,6 @@ function sortQueueJobs(jobs) {
return cmp * factor;
});
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
else _dynamicSortCache = { key, direction, jobsRef: jobs, result: sorted, ts: Date.now() };
return sorted;
}
@ -1985,37 +1969,6 @@ function handleBatchDone(summary) {
}
queueJobs = nextJobs;
renderQueueTable();
} else {
// Auto-prune for the default (removeOnDone=false) too: cap done/skipped
// jobs at the most recent N so the queue can't grow unbounded across
// long sessions. Without this, sortQueueJobs / _computeQueueStats /
// renderQueueTable all become O(N) on a forever-growing N and the UI
// starts visibly lagging once the queue passes a few thousand entries.
// 500 most-recent terminal jobs is enough for "see what just happened"
// while stopping the runaway growth.
const TERMINAL_KEEP_LIMIT = 500;
const terminalIdxs = [];
for (let i = 0; i < queueJobs.length; i++) {
const s = queueJobs[i].status;
if (s === 'done' || s === 'skipped' || s === 'error' || s === 'aborted') terminalIdxs.push(i);
}
if (terminalIdxs.length > TERMINAL_KEEP_LIMIT) {
const dropCount = terminalIdxs.length - TERMINAL_KEEP_LIMIT;
// terminalIdxs is in insertion order → first `dropCount` are the
// oldest. Remove those, keep everything else.
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
const nextJobs = [];
for (let i = 0; i < queueJobs.length; i++) {
if (dropSet.has(i)) {
removeJobFromIndex(queueJobs[i]);
selectedJobIds.delete(queueJobs[i].id);
} else {
nextJobs.push(queueJobs[i]);
}
}
queueJobs = nextJobs;
renderQueueTable();
}
}
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
@ -2234,10 +2187,6 @@ function syncSelectedFilesFromQueue() {
selectedFiles = Array.from(fileMap.values());
}
// Cap recent-files panel growth so a multi-thousand-job session doesn't
// turn every renderRecentUploadsPanel call into a multi-MB innerHTML write.
const SESSION_FILES_CAP = 2000;
function maybeAddSessionFile(job) {
if (!job) return;
@ -2258,18 +2207,6 @@ function maybeAddSessionFile(job) {
order: sessionFilesData.length
});
_sessionDoneCount++;
// Drop oldest entries past the cap to keep render cost bounded.
// Without this, sessionFilesData grows unbounded across the session
// and every renderRecentUploadsPanel call becomes a megabyte-sized
// innerHTML write — visible as scroll/click lag in the lower panel.
if (sessionFilesData.length > SESSION_FILES_CAP) {
const drop = sessionFilesData.length - SESSION_FILES_CAP;
for (let i = 0; i < drop; i++) {
const r = sessionFilesData[i];
_sessionFileKeys.delete(`${r.link}${r.filename}${r.host}`);
}
sessionFilesData = sessionFilesData.slice(drop);
}
// Coalesce rapid successive adds into one render per frame.
scheduleRecentRender();
}
@ -3640,28 +3577,22 @@ function renderHistoryTable(container) {
html += '</tbody></table>';
container.innerHTML = html;
// Delegated listeners: bind once per render-target instead of once per
// row/header. With a 5000-row history the per-row bind path was a
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
// dominant cause of "tab switching lags" in the user report.
if (!container.dataset.historyListenersBound) {
container.dataset.historyListenersBound = '1';
container.addEventListener('click', (e) => {
const th = e.target.closest('th.sortable');
if (th && container.contains(th)) {
const key = th.dataset.historySort;
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
renderHistoryTable(container);
return;
}
const row = e.target.closest('.history-row');
if (row && !row.classList.contains('error')) {
const link = row.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
}
container.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.historySort;
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
renderHistoryTable(container);
});
}
});
container.querySelectorAll('.history-row').forEach(row => {
row.addEventListener('click', () => {
if (row.classList.contains('error')) return;
const link = row.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
});
});
}
function sortHistoryRows(rows) {

View File

@ -1,32 +1,27 @@
# 3.3.0 — Performance + Log-Recovery
# Perf/Stabilität Audit Log
## Probleme (User-Bericht)
1. Lag beim Tab-Wechsel + Scrollen nach langer Session.
2. File-Uploader-Log: bei einem von zwei Hostern gar keine Log-Einträge — möglicherweise nach Datei/Verzeichnis-Löschung kein Recovery.
## Abgeschlossen in dieser Session
## Root Causes (aus Audit)
- [x] **3.1.3** — Doppel-Render beim Retry vieler Jobs entfernt.
- [x] **3.1.4** — Byse disk-space als account-level klassifiziert (vorher fälschlich file-rejected).
- [x] **3.1.5** — Pre-job-swap hinter Semaphore-Queue + Late-Resolve bei save-config.
- [x] **3.1.6**`JSON.stringify(files/hosters)` aus start-upload debugLog raus.
- [x] **3.1.7** — Status-Change-Events im Renderer via rAF coalesced.
- [x] **3.1.8** — Byse-Poller race-condition fix (kein "newFiles.length===1"-Fallback mehr) + transient-network-classifier mit 2 Tests abgesichert + Memory-Snapshot-Logger bei Batch-Boundaries.
### Performance
- **queueJobs wächst unbounded** (Default `removeFromQueueOnDone=false`). Sortierung + Stats + In-place-Render skalieren O(N) und O(N log N) je render. Nach ~5000 Jobs spürbar.
- **`sortQueueJobs` cached nur für static keys** (filename, host). Bei status/speed/progress: voller Sort jedes Mal.
- **`renderHistoryTable`** bindet per-row click listener + voller `innerHTML`-Rebuild bei jedem Tab-Switch.
- **`renderRecentUploadsPanel`** baut komplettes innerHTML bei sort-change neu, `sessionFilesData` Array ungecapt.
## Getestet / validiert
### Log-Bug
- `_flushUploadLog` clear buffer **vor** appendFile. Bei `ENOENT` (Datei oder Dir wurde gelöscht) → silent log + buffer verloren.
- `_resolveUploadLogTarget` cached den Path. Bei mid-session Verzeichnis-Löschung wird der cached target wiederverwendet → permanent ENOENT bis Neustart.
- 82 Unit-Tests grün
- Error-Klassifikation (fileRejected / accountError / transient) hat jetzt eindeutige, getestete Trennlinien
- Rotation-Pipeline durchspielbar in Tests (session memory, late-add, override-precedence)
## Plan
- [ ] Log: bei error in `appendFile` → cache invalidieren + buffer prepend + retry
- [ ] Log: vor jedem flush mkdirSync (idempotent, recreated deleted dir)
- [ ] queueJobs: auto-prune nach `handleBatchDone` für älteste 'done' jobs jenseits Cap (z.B. 500)
- [ ] sortQueueJobs: für dynamic keys → coalesce auf 1× pro UI_UPDATE_INTERVAL (200ms)
- [ ] renderHistoryTable: delegated click-listener auf tbody (nicht per-row), short-circuit wenn `_historyDirty=false`
- [ ] renderRecentUploadsPanel: `sessionFilesData` auf letzten 2000 Einträge cappen, `_sessionFileKeys` parallel prunen
- [ ] removeFromQueueOnDone: O(N²) → splice via `_jobIndexById` (oder defer auf batch-done)
- [ ] Tests: Log-recovery, queue-prune, sort-throttle
- [ ] Release als 3.3.0
## Nicht angegangen (Follow-ups)
## Skip (Low Impact / kosmetisch)
- CSS transition fix
- Module-level Sets clearing (genug Memory bisher)
- **Throughput bei 20+ parallelen Uploads** — bräuchte Lasttest-Setup mit Mock-Hoster; speculative ohne User-Beschwerde.
- **Netz-Ausfall-Recovery** — Klassifikator getestet, echter Network-Interrupt-Integrationstest nicht gemacht (aufwendiger Setup, real-world: Transients werden korrekt erkannt).
- **Live Memory-Tracking** — Batch-Boundary-Logging liefert jetzt Datenpunkte. Bei wachsendem `rss`/`heapUsed` über Batches hinweg: Leak-Verdacht, dann in DevTools profilen.
## Bekannte externe Issues (nicht fixbar bei uns)
- Byse "Not video file format" bei manchen MKV-Releases ist Byse-seitige Codec/Container-Validierung. Lösung: Datei vorher remuxen (z.B. mit mkvtoolnix).
- Real-Debrid-Downloader + Multi-Hoster-Upload konkurrieren um File-Handles → WinError 5 beim Rename. Workaround: Downloader komplett durchlaufen lassen bevor Queue gezogen wird.