Refresh host health from the persisted retention-filtered history after batch completion, allowing the existing generation guard to reject stale responses and removing the competing local merge path.
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
(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, skipped: 0, total: 0 });
|
|
bucket.total++;
|
|
if (r.status === 'done') bucket.ok++;
|
|
else if (r.status === 'skipped') bucket.skipped++;
|
|
else bucket.fail++;
|
|
}
|
|
}
|
|
}
|
|
for (const h of Object.keys(out)) {
|
|
const b = out[h];
|
|
const attempted = b.ok + b.fail;
|
|
b.rate = attempted > 0 ? b.ok / attempted : null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function summarizeHosterHealth(history, options = {}) {
|
|
const out = {};
|
|
const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {};
|
|
const accountStatuses = options.accountStatuses && typeof options.accountStatuses === 'object' ? options.accountStatuses : {};
|
|
const failedKeys = new Set(options.sessionFailedKeys instanceof Set
|
|
? options.sessionFailedKeys
|
|
: (Array.isArray(options.sessionFailedKeys) ? options.sessionFailedKeys : []));
|
|
const nowCandidate = options.now instanceof Date
|
|
? options.now.getTime()
|
|
: (Number.isFinite(options.now) ? Number(options.now) : Date.parse(options.now));
|
|
const nowMs = Number.isFinite(nowCandidate) ? nowCandidate : Date.now();
|
|
const recentCutoff = nowMs - 7 * 24 * 60 * 60 * 1000;
|
|
|
|
const ensure = (name) => out[name] || (out[name] = {
|
|
sampleSize: 0,
|
|
successful: 0,
|
|
failed: 0,
|
|
skipped: 0,
|
|
successRate: null,
|
|
effectiveBytes: 0,
|
|
effectiveDurationSec: 0,
|
|
effectiveBytesPerSecond: null,
|
|
lastSuccessAt: null,
|
|
failuresLast7Days: 0,
|
|
configuredAccounts: 0,
|
|
accountProblems: 0,
|
|
uncheckedAccounts: 0,
|
|
checkingAccounts: 0
|
|
});
|
|
|
|
const hasCredentials = (account) => {
|
|
if (!account || typeof account !== 'object' || !account.id) return false;
|
|
if (account.authType === 'api') return Boolean(String(account.apiKey || '').trim());
|
|
if (account.authType === 'login') return Boolean(String(account.username || '').trim() && String(account.password || '').trim());
|
|
return Boolean(String(account.apiKey || '').trim() || (String(account.username || '').trim() && String(account.password || '').trim()));
|
|
};
|
|
|
|
for (const [name, accountsValue] of Object.entries(hosters)) {
|
|
const bucket = ensure(name);
|
|
const accounts = Array.isArray(accountsValue) ? accountsValue : [];
|
|
bucket.configuredAccounts = accounts.length;
|
|
for (const account of accounts) {
|
|
if (account?.enabled === false) continue;
|
|
const status = accountStatuses[account?.id]?.status || 'unchecked';
|
|
const unavailable = !hasCredentials(account);
|
|
const problem = unavailable || failedKeys.has(`${name}:${account?.id || ''}`) || ['error', 'warn', 'otp_required'].includes(status);
|
|
if (problem) bucket.accountProblems++;
|
|
if (!unavailable && status === 'unchecked') bucket.uncheckedAccounts++;
|
|
if (!unavailable && status === 'checking') bucket.checkingAccounts++;
|
|
}
|
|
}
|
|
|
|
const validBatches = (Array.isArray(history) ? history : [])
|
|
.map((batch, index) => {
|
|
const timestampMs = batch?.timestamp ? Date.parse(batch.timestamp) : NaN;
|
|
return { batch, index, timestampMs };
|
|
})
|
|
.filter(({ timestampMs }) => Number.isFinite(timestampMs) && timestampMs <= nowMs);
|
|
const batches = [...validBatches]
|
|
.sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index)
|
|
.slice(0, 50);
|
|
|
|
for (const { batch, timestampMs } of batches) {
|
|
if (!batch || !Array.isArray(batch.files)) continue;
|
|
for (const file of batch.files) {
|
|
if (!file || !Array.isArray(file.results)) continue;
|
|
const fileSize = Number(file.size);
|
|
for (const result of file.results) {
|
|
if (!result?.hoster) continue;
|
|
const bucket = ensure(result.hoster);
|
|
bucket.sampleSize++;
|
|
if (result.status === 'done') {
|
|
bucket.successful++;
|
|
const previous = bucket.lastSuccessAt ? Date.parse(bucket.lastSuccessAt) : -Infinity;
|
|
if (timestampMs > previous) bucket.lastSuccessAt = new Date(timestampMs).toISOString();
|
|
const durationSec = Number(result.durationSec);
|
|
if (Number.isFinite(fileSize) && fileSize > 0 && Number.isFinite(durationSec) && durationSec > 0) {
|
|
bucket.effectiveBytes += fileSize;
|
|
bucket.effectiveDurationSec += durationSec;
|
|
}
|
|
} else if (result.status === 'skipped') {
|
|
bucket.skipped++;
|
|
} else {
|
|
bucket.failed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const { batch, timestampMs } of validBatches) {
|
|
if (timestampMs < recentCutoff || !Array.isArray(batch?.files)) continue;
|
|
for (const file of batch.files) {
|
|
if (!Array.isArray(file?.results)) continue;
|
|
for (const result of file.results) {
|
|
if (!result?.hoster || result.status === 'done' || result.status === 'skipped') continue;
|
|
ensure(result.hoster).failuresLast7Days++;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const bucket of Object.values(out)) {
|
|
const attempted = bucket.successful + bucket.failed;
|
|
bucket.successRate = attempted > 0 ? bucket.successful / attempted : null;
|
|
bucket.effectiveBytesPerSecond = bucket.effectiveDurationSec > 0
|
|
? bucket.effectiveBytes / bucket.effectiveDurationSec
|
|
: 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' || r.status === 'skipped') 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;
|
|
}
|
|
|
|
function mergeSkippedIntoSummary(summary, skippedJobs) {
|
|
const source = summary && typeof summary === 'object' ? summary : {};
|
|
const merged = {
|
|
...source,
|
|
files: Array.isArray(source.files)
|
|
? source.files.map(file => ({ ...file, results: Array.isArray(file.results) ? [...file.results] : [] }))
|
|
: []
|
|
};
|
|
const existingJobIds = new Set();
|
|
const filesByName = new Map();
|
|
for (const file of merged.files) {
|
|
filesByName.set(String(file.name || file.fileName || ''), file);
|
|
for (const result of file.results) {
|
|
if (result?.jobId) existingJobIds.add(result.jobId);
|
|
}
|
|
}
|
|
let added = 0;
|
|
for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) {
|
|
if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue;
|
|
const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || '';
|
|
let file = filesByName.get(fileName);
|
|
if (!file) {
|
|
file = { name: fileName, size: Number(skipped.size) || 0, results: [] };
|
|
merged.files.push(file);
|
|
filesByName.set(fileName, file);
|
|
}
|
|
file.results.push({
|
|
jobId: skipped.jobId || null,
|
|
hoster: skipped.hoster || '',
|
|
status: 'skipped',
|
|
error: skipped.reason || 'Übersprungen'
|
|
});
|
|
if (skipped.jobId) existingJobIds.add(skipped.jobId);
|
|
added++;
|
|
}
|
|
merged.total = (Number(source.total) || 0) + added;
|
|
merged.succeeded = Number(source.succeeded) || 0;
|
|
merged.failed = Number(source.failed) || 0;
|
|
merged.skipped = (Number(source.skipped) || 0) + added;
|
|
return merged;
|
|
}
|
|
|
|
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,
|
|
summarizeHosterHealth,
|
|
classifyErrorCategory,
|
|
summarizeBatchErrors,
|
|
mergeSkippedIntoSummary,
|
|
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);
|