feat(ui): per-hoster success rate, session-paused badge, post-batch retry, link export formats

This commit is contained in:
Administrator 2026-06-07 20:32:35 +02:00
parent 98eba0447d
commit cf35f4401d
8 changed files with 501 additions and 9 deletions

142
lib/stats.js Normal file
View File

@ -0,0 +1,142 @@
(function (root) {
function summarizePerHoster(history, opts) {
const out = {};
if (!Array.isArray(history)) return out;
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
const entries = [...history];
entries.sort((a, b) => {
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
return tb - ta;
});
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
for (const batch of sliced) {
if (!batch || !Array.isArray(batch.files)) continue;
if (cutoff !== null) {
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
if (!ts || ts < cutoff) continue;
}
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || !r.hoster) continue;
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
bucket.total++;
if (r.status === 'done') bucket.ok++;
else bucket.fail++;
}
}
}
for (const h of Object.keys(out)) {
const b = out[h];
b.rate = b.total > 0 ? b.ok / b.total : null;
}
return out;
}
function classifyErrorCategory(err) {
if (!err || typeof err !== 'string') return 'unknown';
const s = err.toLowerCase();
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
return 'unknown';
}
function summarizeBatchErrors(batchSummary) {
const buckets = {
'file-rejected': [],
'account-error': [],
'hoster-transient': [],
'network': [],
'unknown': [],
'aborted': []
};
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
for (const f of batchSummary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || r.status === 'done') continue;
const cat = classifyErrorCategory(r.error);
buckets[cat].push({
fileName: f.name || f.fileName || '',
hoster: r.hoster || '',
error: r.error || '',
jobId: r.jobId || null
});
}
}
return buckets;
}
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
function isRetryableCategory(cat) {
return RETRYABLE_CATEGORIES.has(cat);
}
const CATEGORY_LABELS = {
'file-rejected': 'Datei abgelehnt',
'account-error': 'Account-Problem',
'hoster-transient': 'Hoster-Flake',
'network': 'Netzwerk',
'unknown': 'Unbekannt',
'aborted': 'Abgebrochen'
};
function formatLinks(rows, format) {
if (!Array.isArray(rows)) return '';
const safe = rows.filter(r => r && r.url);
if (safe.length === 0) return '';
switch (format) {
case 'plain':
return safe.map(r => r.url).join('\n');
case 'bbcode':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `[url=${r.url}]${label}[/url]`;
}).join('\n');
case 'markdown':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `- [${label}](${r.url})`;
}).join('\n');
case 'html':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `<a href="${r.url}">${label}</a>`;
}).join('\n');
case 'csv': {
const head = 'fileName,hoster,url\n';
return head + safe.map(r => {
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
}).join('\n');
}
case 'json':
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
default:
return safe.map(r => r.url).join('\n');
}
}
const api = {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory,
RETRYABLE_CATEGORIES,
CATEGORY_LABELS,
formatLinks
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.Stats = api;
}
})(typeof window !== 'undefined' ? window : this);

View File

@ -66,6 +66,16 @@ class UploadManager extends EventEmitter {
return this._accountOverrides.get(hoster) || null;
}
clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`);
}
clearAllFailedAccounts() {
const n = this._failedAccounts.size;
this._failedAccounts.clear();
return n;
}
// True if the hoster has a usable override stored that differs from the
// account currently in the task and isn't itself already marked failed.
// Used by the retry loop to decide "retry on same account vs break to

27
main.js
View File

@ -1575,6 +1575,33 @@ ipcMain.handle('finish-after-active', () => {
return true;
});
ipcMain.handle('get-session-failed-accounts', () => {
return Array.from(_sessionFailedAccounts.keys());
});
ipcMain.handle('reset-session-failed-account', (_event, payload) => {
if (!payload || typeof payload !== 'object') return { ok: false };
const { hoster, accountId } = payload;
if (!hoster || !accountId) return { ok: false };
const key = `${hoster}:${accountId}`;
const removed = _sessionFailedAccounts.delete(key);
if (uploadManager && typeof uploadManager.clearFailedAccount === 'function') {
try { uploadManager.clearFailedAccount(hoster, accountId); } catch {}
}
rotLog(`session-failed: manual reset ${key} (was set: ${removed})`);
return { ok: true, removed };
});
ipcMain.handle('reset-all-session-failed-accounts', () => {
const count = _sessionFailedAccounts.size;
_sessionFailedAccounts.clear();
if (uploadManager && typeof uploadManager.clearAllFailedAccounts === 'function') {
try { uploadManager.clearAllFailedAccounts(); } catch {}
}
rotLog(`session-failed: cleared all (${count})`);
return { ok: true, count };
});
ipcMain.handle('get-job-log', (_event, jobId) => {
if (!jobId || typeof jobId !== 'string') return [];
const arr = _jobLogCollector.get(jobId);

View File

@ -110,6 +110,9 @@ contextBridge.exposeInMainWorld('api', {
},
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),

View File

@ -92,6 +92,7 @@ async function init() {
setupDragDrop();
restoreQueueColumnWidths();
loadHistory();
_refreshSessionFailedSnapshot();
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
@ -2056,6 +2057,85 @@ function handleBatchDone(summary) {
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
updateStatusBar();
_maybeShowBatchSummary(summary);
_refreshSessionFailedSnapshot();
}
let _sessionFailedKeys = new Set();
async function _refreshSessionFailedSnapshot() {
if (!window.api || !window.api.getSessionFailedAccounts) return;
try {
const keys = await window.api.getSessionFailedAccounts();
_sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []);
renderAccounts();
} catch { /* ignore */ }
}
function _maybeShowBatchSummary(summary) {
if (!window.Stats || !summary) return;
const buckets = window.Stats.summarizeBatchErrors(summary);
const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0);
if (total === 0) return;
const modal = document.getElementById('batchSummaryModal');
if (!modal) return;
const list = modal.querySelector('#batchSummaryList');
const retryAllBtn = modal.querySelector('#batchSummaryRetryAll');
const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient');
const closeBtn = modal.querySelector('#batchSummaryClose');
const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted'];
list.innerHTML = order
.filter(cat => buckets[cat].length > 0)
.map(cat => {
const items = buckets[cat];
const sample = items.slice(0, 3).map(i => `<li>${escapeHtml(i.fileName)}${escapeHtml(i.hoster)}: <em>${escapeHtml(i.error)}</em></li>`).join('');
const more = items.length > 3 ? `<li><em>… +${items.length - 3} weitere</em></li>` : '';
const retryable = window.Stats.isRetryableCategory(cat);
const tag = retryable ? '<span class="batch-cat-tag retryable">erneut versuchbar</span>' : '<span class="batch-cat-tag">manuell</span>';
return `<div class="batch-cat" data-category="${escapeAttr(cat)}">
<div class="batch-cat-head"><strong>${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)}</strong> <span class="batch-cat-count">${items.length}</span> ${tag}</div>
<ul class="batch-cat-list">${sample}${more}</ul>
</div>`;
}).join('');
const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0);
retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler';
retryTransientBtn.disabled = transientCount === 0;
const allRetryable = total - buckets['aborted'].length;
retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`;
retryAllBtn.disabled = allRetryable === 0;
const close = () => { modal.style.display = 'none'; };
closeBtn.onclick = close;
retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); };
retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); };
modal.style.display = 'flex';
}
function _retryFailedFromBuckets(buckets, transientOnly) {
const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error'];
const toRetry = [];
for (const cat of cats) {
for (const item of (buckets[cat] || [])) toRetry.push(item);
}
if (toRetry.length === 0) return;
const jobsToRetry = [];
for (const item of toRetry) {
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped'));
if (job) {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.error = null;
job.result = null;
jobsToRetry.push(job);
}
}
if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; }
renderQueueTable();
showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`);
if (typeof startUpload === 'function') startUpload();
}
function handleStats(data) {
@ -3118,11 +3198,16 @@ function _buildAccountCardHtml(name, account, idx) {
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`);
const sessionPausedBadge = isSessionPaused
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.">Pausiert (Session) <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren">↻</button></span>`
: '';
return `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card${isDisabled ? ' account-disabled' : ''}${isSessionPaused ? ' account-session-paused-card' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
@ -3267,6 +3352,10 @@ function _buildAccountHosterGroupHtml(name, accounts) {
let cardsHtml = '';
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
const bodyStyle = isOpen ? '' : 'style="display:none"';
const lifeStat = _hosterLifetimeStat(name);
const lifeMeta = lifeStat && lifeStat.total > 0
? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>`
: '';
return `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
<span class="panel-arrow">${arrow}</span>
@ -3275,11 +3364,21 @@ function _buildAccountHosterGroupHtml(name, accounts) {
<span class="account-hoster-group-count">${countLabel}</span>
${summary.disabled ? `<span class="account-hoster-group-meta">${summary.disabled} deaktiviert</span>` : ''}
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
${lifeMeta}
</div>
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
</div>`;
}
let _hosterLifetimeCache = null;
function _hosterLifetimeStat(name) {
if (!_hosterLifetimeCache && window.Stats && Array.isArray(window._historyForStats)) {
_hosterLifetimeCache = window.Stats.summarizePerHoster(window._historyForStats, { lastNBatches: 50 });
}
return _hosterLifetimeCache ? _hosterLifetimeCache[name] : null;
}
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
// 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
@ -3309,6 +3408,18 @@ function bindAccountListeners(container) {
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
if (btn.dataset.accountReactivate) {
const accountId = btn.dataset.accountReactivate;
const hoster = btn.dataset.accountReactivateHoster;
if (!hoster || !accountId) return;
e.stopPropagation();
window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => {
_sessionFailedKeys.delete(`${hoster}:${accountId}`);
renderAccounts();
showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv — nächste Batch verwendet ihn`);
}).catch(() => {});
return;
}
});
let draggedCard = null;
@ -3798,6 +3909,8 @@ function _hideOtpField() {
// --- History ---
async function loadHistory() {
const history = await window.api.getHistory();
window._historyForStats = history || [];
_invalidateHosterLifetimeCache();
const container = document.getElementById('historyContainer');
if (!history || history.length === 0) {
@ -4385,14 +4498,20 @@ async function importUploadLog() {
// --- Link operations ---
function copyAllLinks() {
const links = queueJobs
const rows = queueJobs
.filter(j => j.status === 'done' && j.result)
.map(j => j.result.download_url || j.result.embed_url || '')
.filter(Boolean);
if (links.length > 0) {
window.api.copyToClipboard(links.join('\n'));
showCopyToast(`${links.length} Links kopiert`);
}
.map(j => ({
fileName: j.fileName || '',
hoster: j.hoster || '',
url: j.result.download_url || j.result.embed_url || ''
}))
.filter(r => r.url);
if (rows.length === 0) return;
const formatEl = document.getElementById('linkExportFormat');
const fmt = (formatEl && formatEl.value) || 'plain';
const text = window.Stats ? window.Stats.formatLinks(rows, fmt) : rows.map(r => r.url).join('\n');
window.api.copyToClipboard(text);
showCopyToast(`${rows.length} Link${rows.length === 1 ? '' : 's'} als ${fmt.toUpperCase()} kopiert`);
}
// --- Utilities ---

View File

@ -93,6 +93,14 @@
<div class="queue-actions" id="queueActions" style="display:none">
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
<option value="plain">Plaintext</option>
<option value="bbcode">BBCode</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
</div>
@ -334,9 +342,26 @@
</div>
</div>
<div class="modal" id="batchSummaryModal" style="display:none">
<div class="modal-content" style="max-width:680px">
<div class="modal-header">
<h2>Batch-Zusammenfassung</h2>
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div id="batchSummaryList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
</div>
</div>
</div>
<script src="../lib/queue-prune.js"></script>
<script src="../lib/queue-dedup.js"></script>
<script src="../lib/log-mode.js"></script>
<script src="../lib/stats.js"></script>
<script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script>
<script src="app.js"></script>

View File

@ -916,6 +916,40 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
color: var(--danger, #e57373);
background: rgba(229, 115, 115, 0.12);
}
.account-session-paused {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #f0c36c;
background: rgba(240, 195, 108, 0.12);
padding: 1px 6px;
border-radius: 4px;
margin-left: 6px;
}
.account-session-reactivate {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.account-session-reactivate:hover { color: #fff; }
.account-session-paused-card { opacity: 0.85; }
.batch-cat {
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255,255,255,0.03);
}
.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); }
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
.batch-cat-list em { color: var(--text-muted); font-style: italic; }
.account-hoster-group-body {
padding: 8px;
border-top: 1px solid var(--border);

132
tests/stats.test.js Normal file
View File

@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert');
const {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory
} = require('../lib/stats');
function makeBatch(timestamp, results) {
return {
id: 'b-' + timestamp,
timestamp: new Date(timestamp).toISOString(),
files: [{ name: 'foo.mp4', size: 1, results }]
};
}
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
const history = [
makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
]),
makeBatch(2, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'byse.sx', status: 'done' }
])
];
const s = summarizePerHoster(history);
assert.strictEqual(s['voe.sx'].ok, 2);
assert.strictEqual(s['voe.sx'].fail, 1);
assert.strictEqual(s['voe.sx'].total, 3);
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
assert.strictEqual(s['byse.sx'].ok, 1);
assert.strictEqual(s['byse.sx'].fail, 1);
assert.strictEqual(s['byse.sx'].rate, 0.5);
});
test('summarizePerHoster honors sinceMs cutoff', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { sinceMs: 3000 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster honors lastNBatches (newest first)', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { lastNBatches: 1 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster(null), {});
assert.deepStrictEqual(summarizePerHoster([]), {});
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
});
test('classifyErrorCategory: account-error phrases', () => {
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
});
test('classifyErrorCategory: hoster-transient phrases', () => {
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
});
test('classifyErrorCategory: network phrases', () => {
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
});
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
assert.strictEqual(isRetryableCategory('aborted'), false);
});
test('classifyErrorCategory: unknown for everything else', () => {
assert.strictEqual(classifyErrorCategory(''), 'unknown');
assert.strictEqual(classifyErrorCategory(null), 'unknown');
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
});
test('summarizeBatchErrors buckets results by category', () => {
const summary = {
files: [
{ name: 'a.mp4', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
] },
{ name: 'b.mp4', results: [
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
] }
]
};
const buckets = summarizeBatchErrors(summary);
assert.strictEqual(buckets['file-rejected'].length, 1);
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
assert.strictEqual(buckets['hoster-transient'].length, 1);
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
assert.strictEqual(buckets['network'].length, 1);
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
assert.strictEqual(buckets['account-error'].length, 0);
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
assert.strictEqual(isRetryableCategory('unknown'), true);
assert.strictEqual(isRetryableCategory('file-rejected'), false);
assert.strictEqual(isRetryableCategory('account-error'), false);
assert.strictEqual(isRetryableCategory('aborted'), false);
});