fix(byse/rotation): surface per-file rejection, skip retries and rotation

The log revealed byse's true response shape for rejected files:
  { msg: 'OK', status: 200, files: [{ filecode: '', status: 'Not video file format' }] }

HTTP 200 + msg=OK made the old code treat it as 'success but no
file_code'. The real error ('Not video file format') was buried in
files[0].status. parseByseResult now surfaces that with a dedicated
err.fileRejected flag so the rotation layer can distinguish
file-specific vs account-specific failures.

Rotation behavior:
  - file-rejected errors: no retries, no account blacklist, no
    rotation. The same file is going to get the same verdict on
    any account, so skip straight to 'error' status and keep the
    account available for other files in the batch.
  - network errors (already handled): no account blacklist either.
  - everything else: unchanged (retry then rotate).

Also added pattern matches for common rejections (Duplicate, File
too small/large, Unsupported format, etc.) so other hosters'
per-file errors get the same treatment.
This commit is contained in:
Administrator 2026-04-20 16:06:09 +02:00
parent c696b0cb0e
commit 7ed227a76e
2 changed files with 44 additions and 1 deletions

View File

@ -196,10 +196,19 @@ function parseVoeResult(payload) {
// Byse: { files: [{ filecode, filename, status }] } // Byse: { files: [{ filecode, filename, status }] }
function parseByseResult(payload) { function parseByseResult(payload) {
let file_code = null; let file_code = null;
let perFileError = null;
// Primary: files array (per official Byse API docs) // Primary: files array (per official Byse API docs)
if (Array.isArray(payload.files) && payload.files.length > 0) { if (Array.isArray(payload.files) && payload.files.length > 0) {
file_code = payload.files[0].filecode || payload.files[0].file_code; const f = payload.files[0];
file_code = f.filecode || f.file_code || null;
// Byse returns HTTP 200 + msg=OK even when a specific file was rejected
// ("Not video file format", "Duplicate", "File too small", ...). When
// filecode is empty and status carries a non-OK message, that IS the
// actual per-file error, not a server problem.
if (!file_code && f.status && !/^(ok|success|done)$/i.test(String(f.status))) {
perFileError = String(f.status).trim();
}
} }
// Fallback: result object // Fallback: result object
if (!file_code && payload.result) { if (!file_code && payload.result) {
@ -211,6 +220,12 @@ function parseByseResult(payload) {
} }
} }
if (!file_code && perFileError) {
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
err.fileRejected = true;
throw err;
}
return { return {
download_url: file_code ? `https://byse.sx/d/${file_code}` : null, download_url: file_code ? `https://byse.sx/d/${file_code}` : null,
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null, embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,

View File

@ -56,6 +56,17 @@ class UploadManager extends EventEmitter {
this.emit('rot-log', { ts: Date.now(), event, ...data }); this.emit('rot-log', { ts: Date.now(), event, ...data });
} }
// File-specific rejections from the hoster: the same file will get rejected
// on any account, so rotation is pointless. Matches the `err.fileRejected`
// flag set by parsers plus known rejection phrases.
_isFileRejectedError(err) {
if (!err) return false;
if (err.fileRejected === true) return true;
if (!err.message) return false;
const m = String(err.message);
return /(Not video file format|Duplicate|Datei zu (klein|gross|groß)|File too (small|large)|Invalid file|Unsupported format|lehnte Datei ab)/i.test(m);
}
// Transient network errors — the account is fine, the network or the // Transient network errors — the account is fine, the network or the
// hoster's own backend hiccuped. Retrying on the SAME account is the right // hoster's own backend hiccuped. Retrying on the SAME account is the right
// move; marking it failed would wrongly poison the fallback chain. If all // move; marking it failed would wrongly poison the fallback chain. If all
@ -530,6 +541,10 @@ class UploadManager extends EventEmitter {
} }
lastError = err; lastError = err;
// File-specific rejection — re-uploading won't change the server's
// mind. Break out immediately; the outer file-rejected branch then
// records the final error without burning through 5 × 3s retries.
if (this._isFileRejectedError(err)) break;
// Account-specific errors — don't waste retries on the same account, // Account-specific errors — don't waste retries on the same account,
// jump straight to rotation. // jump straight to rotation.
if (this._shouldSkipRetryOnAccountError(err)) { if (this._shouldSkipRetryOnAccountError(err)) {
@ -569,6 +584,19 @@ class UploadManager extends EventEmitter {
hoster: task.hoster, fileName, accountId: task.accountId, hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null lastError: lastError ? lastError.message : null
}); });
// File-specific rejection → same file will get the same verdict on
// every other account, rotation is pointless. Don't blacklist, don't
// retry siblings, just fail this file cleanly.
if (this._isFileRejectedError(lastError)) {
this._rotLog('skip-rotation-file-rejected', {
hoster: task.hoster, fileName, accountId: task.accountId,
lastError: lastError ? lastError.message : null
});
const error = lastError.message || 'Datei abgelehnt';
emitFinalStatus('error', { error });
recordFinalResult('error', { error });
return;
}
// If the reason for failure was a transient network error we do NOT // If the reason for failure was a transient network error we do NOT
// blacklist the account. Other jobs on the same account in this batch // blacklist the account. Other jobs on the same account in this batch
// can still try fresh. This file just errors out for now. // can still try fresh. This file just errors out for now.