Compare commits
3 Commits
master
...
rotation-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1786ba8c | ||
|
|
51bc1fb5c1 | ||
|
|
fa3ba67ced |
@ -8,7 +8,11 @@ const SIGNATURES = [
|
||||
{ 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: '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: '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 },
|
||||
|
||||
@ -235,8 +235,18 @@ function parseByseResult(payload) {
|
||||
// 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 err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
|
||||
if (accountLevel) err.accountError = true;
|
||||
else err.fileRejected = true;
|
||||
if (accountLevel) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -441,7 +451,13 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal)
|
||||
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;
|
||||
}
|
||||
@ -494,7 +510,13 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
||||
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;
|
||||
}
|
||||
@ -600,7 +622,20 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
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
|
||||
// filecode="" + misleading status ("Not video file format"). The file shows
|
||||
|
||||
@ -21,10 +21,11 @@ const DEFAULT_SETTINGS = {
|
||||
};
|
||||
|
||||
class UploadManager extends EventEmitter {
|
||||
constructor(hosterSettings, globalSettings) {
|
||||
constructor(hosterSettings, globalSettings, accountPools) {
|
||||
super();
|
||||
this.hosterSettings = hosterSettings || {};
|
||||
this.globalSettings = globalSettings || {};
|
||||
this.accountPools = accountPools || {};
|
||||
this.semaphores = {};
|
||||
this.globalSemaphore = null;
|
||||
this.abortController = new AbortController();
|
||||
@ -41,10 +42,18 @@ class UploadManager extends EventEmitter {
|
||||
this.globalThrottle = null;
|
||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||
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._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) {
|
||||
const prev = this._accountOverrides.get(hoster);
|
||||
this._accountOverrides.set(hoster, fallbackAccount);
|
||||
@ -98,6 +107,18 @@ class UploadManager extends EventEmitter {
|
||||
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
|
||||
// on any account, so rotation is pointless. Matches the `err.fileRejected`
|
||||
// flag set by parsers plus known rejection phrases.
|
||||
@ -294,6 +315,8 @@ class UploadManager extends EventEmitter {
|
||||
// passes the session-scoped failed/override state.
|
||||
this._failedAccounts.clear();
|
||||
this._accountOverrides.clear();
|
||||
this._suspectSizeMemo.clear();
|
||||
this._suspectGoodAccounts.clear();
|
||||
if (Array.isArray(opts.primeFailedAccounts)) {
|
||||
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
|
||||
}
|
||||
@ -457,7 +480,7 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
let fileProbe = null;
|
||||
try {
|
||||
fileProbe = await probeFileHead(task.file, 64);
|
||||
fileProbe = await probeFileHead(task.file, 512);
|
||||
} catch (err) {
|
||||
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 (attempt > 1) {
|
||||
@ -621,7 +659,7 @@ class UploadManager extends EventEmitter {
|
||||
} 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);
|
||||
this.sessionBytes += fileSize;
|
||||
@ -743,7 +781,30 @@ class UploadManager extends EventEmitter {
|
||||
// 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.
|
||||
//
|
||||
// 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 (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', {
|
||||
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||
lastError: lastError ? lastError.message : null
|
||||
@ -782,6 +843,17 @@ class UploadManager extends EventEmitter {
|
||||
}
|
||||
while (task.accountId) {
|
||||
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);
|
||||
if (!alreadyMarked) {
|
||||
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); } }
|
||||
: 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.sessionBytes += fileSize;
|
||||
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
|
||||
@ -893,12 +965,33 @@ class UploadManager extends EventEmitter {
|
||||
} catch (err) {
|
||||
this.activeJobs.delete(uploadId);
|
||||
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 (this._isFileRejectedError(err)) break;
|
||||
if (this._isHosterTransientError(err)) 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';
|
||||
this._rotLog('final-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) {
|
||||
const vidmoly = new VidmolyUploader();
|
||||
await vidmoly.login(task.username, task.password);
|
||||
@ -955,7 +1167,10 @@ class UploadManager extends EventEmitter {
|
||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||
} else {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const task = { ...extra, hoster, accountId: account.id, ...selectUploadAuth(hoster, account) };
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
@ -1554,7 +1572,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
_jobLogCollector.clear();
|
||||
|
||||
// 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;
|
||||
|
||||
const _progressByJob = new Map();
|
||||
@ -1646,7 +1664,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
'mark-failed',
|
||||
'rotation-end',
|
||||
'doodstream-via-api',
|
||||
'doodstream-via-web'
|
||||
'doodstream-via-web',
|
||||
'suspect-reject-alt',
|
||||
'suspect-reject-exhausted'
|
||||
]);
|
||||
uploadManager.on('rot-log', (entry) => {
|
||||
try {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.65",
|
||||
"version": "3.3.66",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -62,3 +62,21 @@
|
||||
- file/list → 90.548 Dateien; Uploads landen server-seitig INTERMITTIEREND (viele Burn-Notice-Folgen genau im "Fehler"-Zeitfenster vorhanden). Das leere Formular ist also nicht "immer kaputt", sondern manchmal — der Web-Form-Registrierungs-Callback (fs-public.intconnect.net) timeoutet sporadisch.
|
||||
**Konsequenz:** API-Weg (result[0].filecode inline) umgeht den failenden Callback → richtiger Fix. file/list-Recovery ist NICHT tote Last (Dateien erscheinen ja) — aber bei 90k-Accounts MUSS man sort=created&order=desc erzwingen, sonst ist die frische Datei nicht auf Seite 1.
|
||||
**Regel:** Bei "geht manchmal/manchmal nicht" + Hoster mit offizieller API: erst per read-only API-Call (account/info, file/list) gegen den ECHTEN Account verifizieren statt am Client weiterzuraten. Das beendet Spekulations-Schleifen.
|
||||
|
||||
## 2026-06-11 — Rotation erreichte Account 4 nie (v3.3.66)
|
||||
- **Hoster-Fehlertexte lügen tier-abhängig:** byse "Not video file format" war für valide
|
||||
MKVs >2,7GB ein Account-Größen-Limit (Beweis: sauberer Size-Split 1158 OK ≤2,62GB vs.
|
||||
6 rejected ≥2,81GB auf demselben Account). Eine "file rejected → Rotation sinnlos"-
|
||||
Klassifizierung darf nie nur auf dem Servertext beruhen, wenn die eigene File-Probe
|
||||
das Gegenteil beweist (probe sagt matroska → Verdikt suspekt → andere Accounts testen).
|
||||
- **Optimierungen gegen dokumentierte Rescue-Pfade diffen:** 1d116ac ("skip 30s poll on
|
||||
explicit reject") hat den Poll exakt für den Fall getötet, dessen Kommentar 5 Zeilen
|
||||
darüber stand. Vor jedem Guard um einen bestehenden Rettungspfad: dessen Kommentar/Tests
|
||||
lesen, sonst zementiert der neue Test die Regression (Test behauptete das Skip-Verhalten).
|
||||
- **Jeder Retry-Pfad braucht dieselbe Fehler-Klassifizierung + dasselbe Logging wie der
|
||||
Hauptpfad:** Der Rotations-Inner-Loop hatte weder upload-failure-Logs (Account 2 starb
|
||||
unsichtbar — Diagnose unmöglich) noch fileRejected-Fast-Breaks (4× 2,7GB-Re-Upload, dann
|
||||
Blacklist eines gesunden Accounts).
|
||||
- **GitHub-Mirror-Falle:** Mirror hat separate bereinigte Historie (ohne electron-config.json).
|
||||
`gh release create` pusht Tags aus der LOKALEN Historie → Credentials-Leak. Prozedur:
|
||||
Cherry-Pick-Worktree auf github/master, Tag auf Mirror-Commit. (Memory: feedback_github_mirror_separate_history)
|
||||
|
||||
@ -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();
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
@ -55,11 +110,38 @@ test('byse explicit "Not video file format" throws fast WITHOUT recovery polling
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -98,3 +98,18 @@ test('summarizeFileStat returns error for missing file', () => {
|
||||
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
|
||||
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