fix(rotation): suspect byse rejections now reach every account instead of dying on the first
Root cause of "4 accounts configured, rotation only ever uses 1-3": byse returns HTTP 200 + per-file status "Not video file format" for valid MKVs above an account's size tier (proven from the live rotation log: account 4ha0 accepted 1158 files up to 2.62 GB and deterministically rejected all 6 files >= 2.81 GB with that status, each costing a full ~20 min upload). The parser tagged this fileRejected, which (a) skipped the 30s recovery poll that was originally BUILT for this misleading status (guard regression from "byse skips 30s recovery poll on explicit reject") and (b) made the upload manager skip rotation entirely - so the remaining accounts were never tried and the files failed forever. Account selection is primary-first failover, so a later account only ever runs when every earlier one is hard-failed or disabled, which matches the reported "account 4 only gets used when I disable the others". The fix, layered: - parseByseResult flags "Not video file format" as err.suspectReject (alongside fileRejected). Genuine rejections (Duplicate, too small, account-level disk-full) behave exactly as before. - uploadFile runs the byse recovery poll again for suspect rejections (the file often registers asynchronously despite the status), but skips it when the caller's file probe says the upload is genuinely not a video (new opts.probeIsVideoLike, threaded from the upload manager's probe). Poll helpers now swallow an abort during their sleep and return null so a cancelled poll surfaces the original error instead of a bare AbortError. - upload-manager: on a suspect rejection of a probe-verified video, the file gets ONE attempt on each remaining pool account (new accountPools from main.js, refreshed on save-config) - WITHOUT blacklisting the rejecting account, which keeps working for smaller files. A per-batch size memo (hoster:account -> smallest rejected size) plus a known-good account preference prevent re-uploading every following oversized file through the whole chain: the second file skips known-limited accounts outright and goes straight to the account that took the first one. Alternates that fail with a genuine account error (quota/ban/full) are marked failed in-batch so no later file burns a multi-GB upload on them; deliberately no account-failed emit there, as repointing the hoster-wide override would reroute normal-sized files away from a healthy primary. - Rotation hardening from the adversarial review: the rotated-to retry loop now fast-breaks on file-rejected/hoster-transient errors instead of burning maxAttempts full uploads, and a file-class error on a rotated account no longer blacklists that account (it fails only the file). Cancelling mid-alternates or mid-rotation now records the job as aborted instead of error (this also stops the batch webhook from firing on fully user-cancelled batches). New upload-failure rot-logs are guarded against logging user aborts as failures; rotation retries are now visible in the rotation log at all (previously failures on a rotated-to account were never logged, which is why account 2's death was invisible in the support log). - file-probe: the mpeg-ts signature required only a single leading 0x47 byte, so GIFs and "G"-prefixed text classified as video and would have qualified junk for pool-wide alternate uploads; it now requires TS sync-byte periodicity (0x47 at offsets 0/188/376) and the probe head read grew from 64 to 512 bytes to make that check possible. GIF gets its own non-video signature. Tests: 9 new/reworked across suspect-reject-alternates.test.js (alternate walk, no blacklist, memo short-circuit, account-error marking, abort labeling, probe gating) and byse-reject-recovery.test.js (suspect polls + recovers, empty poll throws suspectReject, Duplicate still fast-fails, probe-confirmed non-video skips the poll) plus a TS/GIF probe regression test. Full suite 276 green.
This commit is contained in:
parent
e2b0fab446
commit
497317dc32
@ -8,7 +8,11 @@ const SIGNATURES = [
|
|||||||
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
|
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
|
||||||
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
|
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
|
||||||
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
|
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
|
||||||
{ kind: 'mpeg-ts', test: (b) => b.length >= 1 && b[0] === 0x47 },
|
{ kind: 'gif', test: (b) => b.length >= 6 && (b.slice(0, 6).toString('ascii') === 'GIF87a' || b.slice(0, 6).toString('ascii') === 'GIF89a') },
|
||||||
|
// TS demands the 0x47 sync byte every 188 bytes — a single leading 0x47
|
||||||
|
// matches every GIF and every text file starting with "G", so require
|
||||||
|
// three consecutive packet boundaries before classifying as video.
|
||||||
|
{ kind: 'mpeg-ts', test: (b) => b.length >= 377 && b[0] === 0x47 && b[188] === 0x47 && b[376] === 0x47 },
|
||||||
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
|
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
|
||||||
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
|
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
|
||||||
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
|
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
|
||||||
|
|||||||
@ -235,8 +235,18 @@ function parseByseResult(payload) {
|
|||||||
// format, too small/large) ARE per-file and rotation is pointless.
|
// format, too small/large) ARE per-file and rotation is pointless.
|
||||||
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
|
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
|
||||||
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
|
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
|
||||||
if (accountLevel) err.accountError = true;
|
if (accountLevel) {
|
||||||
else err.fileRejected = true;
|
err.accountError = true;
|
||||||
|
} else {
|
||||||
|
err.fileRejected = true;
|
||||||
|
// "Not video file format" is byse's known-misleading status: observed
|
||||||
|
// live (2026-06-09) ONLY on valid MKVs >2.7 GB while the same account
|
||||||
|
// accepted 1100+ smaller MKVs. Per-account size tiers produce it, and
|
||||||
|
// async registration can land the file anyway. Flag it suspect so the
|
||||||
|
// recovery poll still runs and the upload manager may try the file on
|
||||||
|
// the remaining accounts instead of failing it everywhere.
|
||||||
|
if (/not video file format/i.test(perFileError)) err.suspectReject = true;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,7 +451,13 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal)
|
|||||||
file_code: match.file_code
|
file_code: match.file_code
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
|
if (i < POLL_ATTEMPTS - 1) {
|
||||||
|
try {
|
||||||
|
await sleep(POLL_DELAY_MS, signal);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -494,7 +510,13 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
|||||||
file_code: match.file_code
|
file_code: match.file_code
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
|
if (i < POLL_ATTEMPTS - 1) {
|
||||||
|
try {
|
||||||
|
await sleep(POLL_DELAY_MS, signal);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -600,7 +622,20 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitlyRejected = parseErr && (parseErr.fileRejected === true || parseErr.accountError === true);
|
// Explicit rejections skip the recovery poll — EXCEPT suspect ones
|
||||||
|
// (byse "Not video file format", see parseByseResult): for those the file
|
||||||
|
// may have registered asynchronously despite the rejection-looking status,
|
||||||
|
// so the poll must still run. Without this exception the rescue below is
|
||||||
|
// dead for the very case it documents (regression shipped in 3.3.5x).
|
||||||
|
// When the caller's file probe positively says the upload is NOT a video
|
||||||
|
// (opts.probeIsVideoLike === false), the rejection is genuine — skip the
|
||||||
|
// 30s poll for it like any other explicit rejection.
|
||||||
|
const suspectBypass = parseErr
|
||||||
|
&& parseErr.suspectReject === true
|
||||||
|
&& !(opts && opts.probeIsVideoLike === false);
|
||||||
|
const explicitlyRejected = parseErr
|
||||||
|
&& (parseErr.fileRejected === true || parseErr.accountError === true)
|
||||||
|
&& !suspectBypass;
|
||||||
|
|
||||||
// Byse-specific async handling: server accepts the file but responds with
|
// Byse-specific async handling: server accepts the file but responds with
|
||||||
// filecode="" + misleading status ("Not video file format"). The file shows
|
// filecode="" + misleading status ("Not video file format"). The file shows
|
||||||
|
|||||||
@ -21,10 +21,11 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class UploadManager extends EventEmitter {
|
class UploadManager extends EventEmitter {
|
||||||
constructor(hosterSettings, globalSettings) {
|
constructor(hosterSettings, globalSettings, accountPools) {
|
||||||
super();
|
super();
|
||||||
this.hosterSettings = hosterSettings || {};
|
this.hosterSettings = hosterSettings || {};
|
||||||
this.globalSettings = globalSettings || {};
|
this.globalSettings = globalSettings || {};
|
||||||
|
this.accountPools = accountPools || {};
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
@ -41,10 +42,18 @@ class UploadManager extends EventEmitter {
|
|||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
|
this._suspectSizeMemo = new Map(); // 'hoster:accountId' -> smallest fileSize that got a suspect rejection
|
||||||
|
this._suspectGoodAccounts = new Map(); // hoster -> accountId that accepted a suspect-class file
|
||||||
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||||
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAccountPools(accountPools) {
|
||||||
|
if (accountPools && typeof accountPools === 'object') {
|
||||||
|
this.accountPools = accountPools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switchAccount(hoster, fallbackAccount) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
const prev = this._accountOverrides.get(hoster);
|
const prev = this._accountOverrides.get(hoster);
|
||||||
this._accountOverrides.set(hoster, fallbackAccount);
|
this._accountOverrides.set(hoster, fallbackAccount);
|
||||||
@ -98,6 +107,18 @@ class UploadManager extends EventEmitter {
|
|||||||
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_noteSuspectReject(hoster, accountId, fileSize) {
|
||||||
|
if (!accountId || !Number.isFinite(fileSize) || fileSize <= 0) return;
|
||||||
|
const key = hoster + ':' + accountId;
|
||||||
|
const prev = this._suspectSizeMemo.get(key);
|
||||||
|
if (prev === undefined || fileSize < prev) this._suspectSizeMemo.set(key, fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
_suspectMemoBlocks(hoster, accountId, fileSize) {
|
||||||
|
const memoSize = this._suspectSizeMemo.get(hoster + ':' + accountId);
|
||||||
|
return memoSize !== undefined && fileSize >= memoSize;
|
||||||
|
}
|
||||||
|
|
||||||
// File-specific rejections from the hoster: the same file will get rejected
|
// File-specific rejections from the hoster: the same file will get rejected
|
||||||
// on any account, so rotation is pointless. Matches the `err.fileRejected`
|
// on any account, so rotation is pointless. Matches the `err.fileRejected`
|
||||||
// flag set by parsers plus known rejection phrases.
|
// flag set by parsers plus known rejection phrases.
|
||||||
@ -294,6 +315,8 @@ class UploadManager extends EventEmitter {
|
|||||||
// passes the session-scoped failed/override state.
|
// passes the session-scoped failed/override state.
|
||||||
this._failedAccounts.clear();
|
this._failedAccounts.clear();
|
||||||
this._accountOverrides.clear();
|
this._accountOverrides.clear();
|
||||||
|
this._suspectSizeMemo.clear();
|
||||||
|
this._suspectGoodAccounts.clear();
|
||||||
if (Array.isArray(opts.primeFailedAccounts)) {
|
if (Array.isArray(opts.primeFailedAccounts)) {
|
||||||
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
|
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
|
||||||
}
|
}
|
||||||
@ -457,7 +480,7 @@ class UploadManager extends EventEmitter {
|
|||||||
|
|
||||||
let fileProbe = null;
|
let fileProbe = null;
|
||||||
try {
|
try {
|
||||||
fileProbe = await probeFileHead(task.file, 64);
|
fileProbe = await probeFileHead(task.file, 512);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
|
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
|
||||||
}
|
}
|
||||||
@ -502,7 +525,22 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
// A previous file of at least this size already got a suspect rejection
|
||||||
|
// on this exact account — skip the guaranteed-to-fail multi-GB upload
|
||||||
|
// and go straight to the alternate-account walk below.
|
||||||
|
let memoSuspect = null;
|
||||||
|
if (fileProbe && fileProbe.isVideoLike === true && task.accountId
|
||||||
|
&& this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) {
|
||||||
|
memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
|
||||||
|
memoSuspect.fileRejected = true;
|
||||||
|
memoSuspect.suspectReject = true;
|
||||||
|
lastError = memoSuspect;
|
||||||
|
this._rotLog('suspect-memo-skip', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts && !memoSuspect; attempt++) {
|
||||||
if (signal.aborted || this.stopAfterActive) break;
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
|
||||||
if (attempt > 1) {
|
if (attempt > 1) {
|
||||||
@ -621,7 +659,7 @@ class UploadManager extends EventEmitter {
|
|||||||
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
|
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
|
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe);
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
||||||
this.sessionBytes += fileSize;
|
this.sessionBytes += fileSize;
|
||||||
@ -743,7 +781,30 @@ class UploadManager extends EventEmitter {
|
|||||||
// File-specific rejection → same file will get the same verdict on
|
// File-specific rejection → same file will get the same verdict on
|
||||||
// every other account, rotation is pointless. Don't blacklist, don't
|
// every other account, rotation is pointless. Don't blacklist, don't
|
||||||
// retry siblings, just fail this file cleanly.
|
// retry siblings, just fail this file cleanly.
|
||||||
|
//
|
||||||
|
// EXCEPT suspect rejections (err.suspectReject, e.g. byse "Not video
|
||||||
|
// file format" on a probe-verified video): those verdicts are
|
||||||
|
// account-conditional in practice (per-account size tiers), so the file
|
||||||
|
// gets one attempt on each remaining account — WITHOUT blacklisting the
|
||||||
|
// current one, which keeps working for files the hoster does accept.
|
||||||
if (this._isFileRejectedError(lastError)) {
|
if (this._isFileRejectedError(lastError)) {
|
||||||
|
if (lastError.suspectReject === true && fileProbe && fileProbe.isVideoLike === true) {
|
||||||
|
this._noteSuspectReject(task.hoster, task.accountId, fileSize);
|
||||||
|
const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe });
|
||||||
|
if (alt) {
|
||||||
|
emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
|
||||||
|
recordFinalResult('done', { result: alt.result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stoppedInAlternates = this.stopAfterActive && !signal.aborted;
|
||||||
|
const abortedInAlternates = signal.aborted || this.cancelledJobIds.has(jobId);
|
||||||
|
if (stoppedInAlternates || abortedInAlternates) {
|
||||||
|
const error = stoppedInAlternates ? 'Warteschlange angehalten' : 'Abgebrochen';
|
||||||
|
emitFinalStatus('aborted', { error });
|
||||||
|
recordFinalResult('aborted', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this._rotLog('skip-rotation-file-rejected', {
|
this._rotLog('skip-rotation-file-rejected', {
|
||||||
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
lastError: lastError ? lastError.message : null
|
lastError: lastError ? lastError.message : null
|
||||||
@ -782,6 +843,17 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
while (task.accountId) {
|
while (task.accountId) {
|
||||||
if (signal.aborted || this.stopAfterActive) break;
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
// The rotated-to account failed with a file-class error (file
|
||||||
|
// rejection / hoster flake / network) — blacklisting it for that
|
||||||
|
// would poison a working account for the whole batch. Fail only
|
||||||
|
// this file instead.
|
||||||
|
if (this._isFileRejectedError(lastError) || this._isHosterTransientError(lastError) || this._isTransientNetworkError(lastError)) {
|
||||||
|
this._rotLog('skip-rotation-after-rotate', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId);
|
const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId);
|
||||||
if (!alreadyMarked) {
|
if (!alreadyMarked) {
|
||||||
this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
|
this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
|
||||||
@ -884,7 +956,7 @@ class UploadManager extends EventEmitter {
|
|||||||
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||||
: hosterThrottle || globalThrottle;
|
: hosterThrottle || globalThrottle;
|
||||||
|
|
||||||
const result = await this._executeUpload(task, progressCb, signal, throttle);
|
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
|
||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
this.sessionBytes += fileSize;
|
this.sessionBytes += fileSize;
|
||||||
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
|
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
|
||||||
@ -893,12 +965,33 @@ class UploadManager extends EventEmitter {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
lastError = err;
|
lastError = err;
|
||||||
|
if (!signal.aborted) {
|
||||||
|
this._rotLog('upload-failure', {
|
||||||
|
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
||||||
|
attempt,
|
||||||
|
error: err && err.message ? err.message : String(err),
|
||||||
|
fileRejected: !!(err && err.fileRejected),
|
||||||
|
accountError: !!(err && err.accountError),
|
||||||
|
hosterTransient: !!(err && err.hosterTransient),
|
||||||
|
rotationRetry: true
|
||||||
|
});
|
||||||
|
}
|
||||||
if (signal.aborted || this.stopAfterActive) break;
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
if (this._isFileRejectedError(err)) break;
|
||||||
|
if (this._isHosterTransientError(err)) break;
|
||||||
if (attempt >= maxAttempts) break;
|
if (attempt >= maxAttempts) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stoppedLate = this.stopAfterActive && !signal.aborted;
|
||||||
|
const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId);
|
||||||
|
if (stoppedLate || abortedLate) {
|
||||||
|
const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen';
|
||||||
|
emitFinalStatus('aborted', { error });
|
||||||
|
recordFinalResult('aborted', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
|
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
|
||||||
this._rotLog('final-error', {
|
this._rotLog('final-error', {
|
||||||
jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error
|
jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error
|
||||||
@ -923,7 +1016,126 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _executeUpload(task, progressCb, signal, throttle) {
|
async _trySuspectRejectAlternates(task, ctx) {
|
||||||
|
const { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe } = ctx;
|
||||||
|
const pool = this.accountPools && Array.isArray(this.accountPools[task.hoster])
|
||||||
|
? this.accountPools[task.hoster]
|
||||||
|
: [];
|
||||||
|
const original = { accountId: task.accountId, username: task.username, password: task.password, apiKey: task.apiKey };
|
||||||
|
const goodId = this._suspectGoodAccounts.get(task.hoster);
|
||||||
|
const ordered = [];
|
||||||
|
for (const account of pool) {
|
||||||
|
if (account && account.id === goodId) ordered.unshift(account);
|
||||||
|
else ordered.push(account);
|
||||||
|
}
|
||||||
|
const tried = new Set([task.accountId]);
|
||||||
|
let attempted = 0;
|
||||||
|
for (const account of ordered) {
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
if (!account || !account.id || tried.has(account.id)) continue;
|
||||||
|
if (this._failedAccounts.has(task.hoster + ':' + account.id)) continue;
|
||||||
|
tried.add(account.id);
|
||||||
|
if (this._suspectMemoBlocks(task.hoster, account.id, fileSize)) {
|
||||||
|
this._rotLog('suspect-memo-skip-alt', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: account.id, fileSize
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attempted += 1;
|
||||||
|
this._rotLog('suspect-reject-alt', {
|
||||||
|
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id
|
||||||
|
});
|
||||||
|
task.accountId = account.id;
|
||||||
|
task.username = account.username;
|
||||||
|
task.password = account.password;
|
||||||
|
task.apiKey = account.apiKey;
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
|
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
|
||||||
|
speedKbs: 0, elapsed: 0, remaining: 0,
|
||||||
|
error: 'Ablehnung verdächtig - Versuch auf anderem Account', result: null, attempt: 1, maxAttempts: 1
|
||||||
|
});
|
||||||
|
const jobStart = Date.now();
|
||||||
|
let lastBytes = 0;
|
||||||
|
let lastSpeedTime = jobStart;
|
||||||
|
let currentSpeedKbs = 0;
|
||||||
|
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
|
||||||
|
this.activeJobs.set(uploadId, activeEntry);
|
||||||
|
const progressCb = (bytesUploaded, bytesTotal) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDelta = (now - lastSpeedTime) / 1000;
|
||||||
|
if (timeDelta >= 1) {
|
||||||
|
currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024);
|
||||||
|
lastBytes = bytesUploaded;
|
||||||
|
lastSpeedTime = now;
|
||||||
|
}
|
||||||
|
activeEntry.speedKbs = currentSpeedKbs;
|
||||||
|
activeEntry.bytesUploaded = bytesUploaded;
|
||||||
|
const elapsed = Math.round((now - jobStart) / 1000);
|
||||||
|
const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0;
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
|
jobId, status: 'uploading',
|
||||||
|
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
||||||
|
bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs,
|
||||||
|
elapsed, remaining, error: null, result: null, attempt: 1, maxAttempts: 1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null;
|
||||||
|
const globalThrottle = this._getGlobalThrottle();
|
||||||
|
const throttle = hosterThrottle && globalThrottle
|
||||||
|
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||||
|
: hosterThrottle || globalThrottle;
|
||||||
|
try {
|
||||||
|
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
|
||||||
|
this.activeJobs.delete(uploadId);
|
||||||
|
this.sessionBytes += fileSize;
|
||||||
|
this._suspectGoodAccounts.set(task.hoster, account.id);
|
||||||
|
return { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000) };
|
||||||
|
} catch (err) {
|
||||||
|
this.activeJobs.delete(uploadId);
|
||||||
|
if (!signal.aborted) {
|
||||||
|
this._rotLog('upload-failure', {
|
||||||
|
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
||||||
|
attempt: 1,
|
||||||
|
error: err && err.message ? err.message : String(err),
|
||||||
|
fileRejected: !!(err && err.fileRejected),
|
||||||
|
accountError: !!(err && err.accountError),
|
||||||
|
hosterTransient: !!(err && err.hosterTransient),
|
||||||
|
suspectAlternate: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
if (err && err.suspectReject === true) {
|
||||||
|
this._noteSuspectReject(task.hoster, account.id, fileSize);
|
||||||
|
}
|
||||||
|
// A genuine account-class error (quota, ban, full disk) positively
|
||||||
|
// identifies a dead account — remember it so parallel and later
|
||||||
|
// suspect jobs stop re-uploading multi-GB files to it. Deliberately
|
||||||
|
// no 'account-failed' emit: that would re-point the hoster-wide
|
||||||
|
// override and reroute normal-sized files away from a primary that
|
||||||
|
// still works for them.
|
||||||
|
if (err && err.accountError === true) {
|
||||||
|
this._failedAccounts.set(task.hoster + ':' + account.id, true);
|
||||||
|
this._rotLog('mark-failed', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: account.id,
|
||||||
|
lastError: err && err.message ? err.message : String(err),
|
||||||
|
suspectAlternate: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.accountId = original.accountId;
|
||||||
|
task.username = original.username;
|
||||||
|
task.password = original.password;
|
||||||
|
task.apiKey = original.apiKey;
|
||||||
|
if (!signal.aborted && !this.stopAfterActive) {
|
||||||
|
this._rotLog('suspect-reject-exhausted', {
|
||||||
|
jobId, hoster: task.hoster, fileName, alternatesTried: attempted
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _executeUpload(task, progressCb, signal, throttle, fileProbe) {
|
||||||
if (task.hoster === 'vidmoly.me' && task.username) {
|
if (task.hoster === 'vidmoly.me' && task.username) {
|
||||||
const vidmoly = new VidmolyUploader();
|
const vidmoly = new VidmolyUploader();
|
||||||
await vidmoly.login(task.username, task.password);
|
await vidmoly.login(task.username, task.password);
|
||||||
@ -955,7 +1167,10 @@ class UploadManager extends EventEmitter {
|
|||||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||||
} else {
|
} else {
|
||||||
const baselineOpts = {};
|
const baselineOpts = {};
|
||||||
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
if (task.hoster === 'byse.sx') {
|
||||||
|
baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
||||||
|
if (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true;
|
||||||
|
}
|
||||||
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
|
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
|
||||||
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
|
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
|
||||||
}
|
}
|
||||||
|
|||||||
24
main.js
24
main.js
@ -731,6 +731,17 @@ function getNextFallbackAccount(config, hosterName, failedAccountId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAccountPools(config) {
|
||||||
|
const pools = {};
|
||||||
|
const all = config && config.hosters ? config.hosters : {};
|
||||||
|
for (const [hoster, accounts] of Object.entries(all)) {
|
||||||
|
if (!Array.isArray(accounts)) continue;
|
||||||
|
const usable = accounts.filter(a => a && a.enabled !== false && hosterAccountHasCreds(hoster, a));
|
||||||
|
if (usable.length > 0) pools[hoster] = usable;
|
||||||
|
}
|
||||||
|
return pools;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTaskFromAccount(hoster, account, extra) {
|
function buildTaskFromAccount(hoster, account, extra) {
|
||||||
const task = { ...extra, hoster, accountId: account.id, ...selectUploadAuth(hoster, account) };
|
const task = { ...extra, hoster, accountId: account.id, ...selectUploadAuth(hoster, account) };
|
||||||
return task;
|
return task;
|
||||||
@ -1290,6 +1301,13 @@ ipcMain.handle('save-config', async (_event, config) => {
|
|||||||
debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`);
|
debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (uploadManager && typeof uploadManager.updateAccountPools === 'function') {
|
||||||
|
try {
|
||||||
|
uploadManager.updateAccountPools(buildAccountPools(configStore.load()));
|
||||||
|
} catch (err) {
|
||||||
|
debugLog(`save-config pool refresh failed: ${err && err.message ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1560,7 +1578,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
_jobLogCollector.clear();
|
_jobLogCollector.clear();
|
||||||
|
|
||||||
// Pass hoster settings to the upload manager
|
// Pass hoster settings to the upload manager
|
||||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
|
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
||||||
globalThis._mhuUploadManagerRef = uploadManager;
|
globalThis._mhuUploadManagerRef = uploadManager;
|
||||||
|
|
||||||
const _progressByJob = new Map();
|
const _progressByJob = new Map();
|
||||||
@ -1652,7 +1670,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
'mark-failed',
|
'mark-failed',
|
||||||
'rotation-end',
|
'rotation-end',
|
||||||
'doodstream-via-api',
|
'doodstream-via-api',
|
||||||
'doodstream-via-web'
|
'doodstream-via-web',
|
||||||
|
'suspect-reject-alt',
|
||||||
|
'suspect-reject-exhausted'
|
||||||
]);
|
]);
|
||||||
uploadManager.on('rot-log', (entry) => {
|
uploadManager.on('rot-log', (entry) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -35,7 +35,62 @@ function stubByseUploadServer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('byse explicit "Not video file format" throws fast WITHOUT recovery polling', async () => {
|
test('byse "Not video file format" (suspect) DOES poll recovery and claims the async-registered file', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/api\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
const body = listCalls === 1
|
||||||
|
? '{"status":200,"result":{"files":[]}}'
|
||||||
|
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'BIGMKV77', title: path.basename(tmpFile) }] } });
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.strictEqual(res.file_code, 'BIGMKV77');
|
||||||
|
assert.ok(listCalls >= 2, 'suspect rejection must still run the recovery poll (live 2026-06-09: >2.7GB MKVs got this status while registering fine)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse "Not video file format" with empty poll throws err.suspectReject so rotation can try other accounts', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
const abort = new AbortController();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/api\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
if (listCalls >= 2) abort.abort();
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||||
|
(err) => err.fileRejected === true && err.suspectReject === true && /Not video file format/i.test(err.message)
|
||||||
|
);
|
||||||
|
assert.ok(listCalls >= 2, 'poll must have started before giving up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse "Not video file format" with probe-confirmed NON-video skips the recovery poll (genuine rejection)', async () => {
|
||||||
stubByseUploadServer();
|
stubByseUploadServer();
|
||||||
let listCalls = 0;
|
let listCalls = 0;
|
||||||
requestRouter = async (url, opts) => {
|
requestRouter = async (url, opts) => {
|
||||||
@ -55,11 +110,38 @@ test('byse explicit "Not video file format" throws fast WITHOUT recovery polling
|
|||||||
};
|
};
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null, { probeIsVideoLike: false }),
|
||||||
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on explicit rejection');
|
assert.strictEqual(listCalls, 1, 'probe says non-video → the rejection is genuine, no 15-attempt poll');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery polling', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/api\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Duplicate' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.fileRejected === true && err.suspectReject !== true && /Duplicate/i.test(err.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on a genuine rejection');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
|
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
|
||||||
|
|||||||
@ -98,3 +98,18 @@ test('summarizeFileStat returns error for missing file', () => {
|
|||||||
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
|
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
|
||||||
assert.ok(stat.error);
|
assert.ok(stat.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detectKind requires TS sync-byte periodicity — GIF and G-prefixed text are NOT mpeg-ts', () => {
|
||||||
|
const ts = Buffer.alloc(377, 0xFF);
|
||||||
|
ts[0] = 0x47; ts[188] = 0x47; ts[376] = 0x47;
|
||||||
|
assert.strictEqual(detectKind(ts), 'mpeg-ts');
|
||||||
|
assert.strictEqual(isVideoLikeKind('mpeg-ts'), true);
|
||||||
|
|
||||||
|
const gif = Buffer.concat([Buffer.from('GIF89a', 'ascii'), Buffer.alloc(400, 0x00)]);
|
||||||
|
assert.strictEqual(detectKind(gif), 'gif');
|
||||||
|
assert.strictEqual(isVideoLikeKind('gif'), false);
|
||||||
|
|
||||||
|
const gText = Buffer.concat([Buffer.from('Gewinnerliste 2026\n', 'ascii'), Buffer.alloc(400, 0x20)]);
|
||||||
|
assert.notStrictEqual(detectKind(gText), 'mpeg-ts');
|
||||||
|
assert.strictEqual(isVideoLikeKind(detectKind(gText)), false);
|
||||||
|
});
|
||||||
|
|||||||
222
tests/suspect-reject-alternates.test.js
Normal file
222
tests/suspect-reject-alternates.test.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
const { describe, it, beforeEach, mock } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
describe('suspect-reject alternate accounts', () => {
|
||||||
|
let UploadManager;
|
||||||
|
let mockUploadFile;
|
||||||
|
let mockProbe;
|
||||||
|
|
||||||
|
function suspectErr() {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: Not video file format');
|
||||||
|
e.fileRejected = true;
|
||||||
|
e.suspectReject = true;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete require.cache[require.resolve('../lib/upload-manager')];
|
||||||
|
|
||||||
|
const hosters = require('../lib/hosters');
|
||||||
|
mockUploadFile = mock.fn(async () => ({ download_url: 'https://byse.sx/d/ok', embed_url: null, file_code: 'ok' }));
|
||||||
|
hosters.uploadFile = (...a) => mockUploadFile(...a);
|
||||||
|
hosters.prefetchBaseline = async () => null;
|
||||||
|
|
||||||
|
const fileProbe = require('../lib/file-probe');
|
||||||
|
mockProbe = mock.fn(async () => ({ ok: true, kind: 'matroska', isVideoLike: true, headHex: '1a45dfa3' }));
|
||||||
|
fileProbe.probeFileHead = (...a) => mockProbe(...a);
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const origStatSync = fs.statSync;
|
||||||
|
fs.statSync = function (p) {
|
||||||
|
if (typeof p === 'string' && p.startsWith('/test/')) return { size: 3 * 1024 * 1024 * 1024 };
|
||||||
|
return origStatSync.call(this, p);
|
||||||
|
};
|
||||||
|
|
||||||
|
UploadManager = require('../lib/upload-manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
function poolMgr(pool, settings) {
|
||||||
|
return new UploadManager({ 'byse.sx': { retries: 0, ...(settings || {}) } }, {}, { 'byse.sx': pool });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('tries the file on the next pool account after a suspect rejection and succeeds without blacklisting', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/alt', embed_url: null, file_code: 'alt' };
|
||||||
|
});
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 1);
|
||||||
|
assert.equal(summary.failed, 0);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2']);
|
||||||
|
assert.ok(rotEvents.includes('suspect-reject-alt'));
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0, 'suspect rejection must not blacklist any account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the file when every pool account gives the suspect rejection — each tried exactly once, none blacklisted', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3']);
|
||||||
|
assert.ok(rotEvents.includes('suspect-reject-exhausted'));
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pool accounts already marked failed and lands on the last one', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' },
|
||||||
|
{ id: 'acc4', apiKey: 'key4' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key3') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/four', embed_url: null, file_code: 'four' };
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch(
|
||||||
|
[{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key3', accountId: 'acc3' }],
|
||||||
|
{ primeFailedAccounts: ['byse.sx:acc1', 'byse.sx:acc2'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 1);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key3', 'key4'], 'failed acc1/acc2 skipped, fourth account finally gets the file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT try alternates when the probe says the file is not a video', async () => {
|
||||||
|
mockProbe.mock.mockImplementation(async () => ({ ok: true, kind: 'rar', isVideoLike: false, headHex: '52617221' }));
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/archive.rar', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
assert.equal(mockUploadFile.mock.calls.length, 1, 'genuine non-video rejection must not burn uploads on other accounts');
|
||||||
|
assert.ok(rotEvents.includes('skip-rotation-file-rejected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a user cancel during the alternates walk as aborted, not error', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
mgr.cancel();
|
||||||
|
const e = new Error('This operation was aborted');
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.files[0].results[0].status, 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an alternate failed on a genuine account error so later suspect files skip it', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { parallelCount: 1 });
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
if (apiKey === 'key2') {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
|
||||||
|
e.accountError = true;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/big1.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/big2.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 2);
|
||||||
|
assert.ok(mgr.getFailedAccountKeys().includes('byse.sx:acc2'), 'dead alternate must be remembered');
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key3'], 'second file must skip the memoized primary AND the dead alternate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('size memo short-circuits later oversized files straight to the known-good account', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { parallelCount: 1 });
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
|
||||||
|
});
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/big1.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/big2.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 2);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key3'], 'no repeat multi-GB upload to size-limited accounts for the second file');
|
||||||
|
assert.ok(rotEvents.includes('suspect-memo-skip'), 'second file must skip its primary via the size memo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain fileRejected without suspect flag keeps the old fast-fail behavior', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: Duplicate');
|
||||||
|
e.fileRejected = true;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user