Compare commits

..

No commits in common. "51bc1fb5c192ebef76ee1918a3edcaab86cd3ff6" and "221eb55380828ebfd2949c5632bd0b5f512ce691" have entirely different histories.

8 changed files with 19 additions and 612 deletions

View File

@ -8,11 +8,7 @@ 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: '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: 'mpeg-ts', test: (b) => b.length >= 1 && b[0] === 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 },

View File

@ -235,18 +235,8 @@ 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;
// "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;
}
if (accountLevel) err.accountError = true;
else err.fileRejected = true;
throw err;
}
@ -451,13 +441,7 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal)
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) {
try {
await sleep(POLL_DELAY_MS, signal);
} catch {
return null;
}
}
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
}
return null;
}
@ -510,13 +494,7 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) {
try {
await sleep(POLL_DELAY_MS, signal);
} catch {
return null;
}
}
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
}
return null;
}
@ -622,20 +600,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
return result;
}
// 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;
const explicitlyRejected = parseErr && (parseErr.fileRejected === true || parseErr.accountError === true);
// Byse-specific async handling: server accepts the file but responds with
// filecode="" + misleading status ("Not video file format"). The file shows

View File

@ -21,11 +21,10 @@ const DEFAULT_SETTINGS = {
};
class UploadManager extends EventEmitter {
constructor(hosterSettings, globalSettings, accountPools) {
constructor(hosterSettings, globalSettings) {
super();
this.hosterSettings = hosterSettings || {};
this.globalSettings = globalSettings || {};
this.accountPools = accountPools || {};
this.semaphores = {};
this.globalSemaphore = null;
this.abortController = new AbortController();
@ -42,18 +41,10 @@ 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);
@ -107,18 +98,6 @@ 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.
@ -315,8 +294,6 @@ 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);
}
@ -480,7 +457,7 @@ class UploadManager extends EventEmitter {
let fileProbe = null;
try {
fileProbe = await probeFileHead(task.file, 512);
fileProbe = await probeFileHead(task.file, 64);
} catch (err) {
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
}
@ -525,22 +502,7 @@ class UploadManager extends EventEmitter {
}
}
// 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++) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@ -659,7 +621,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, fileProbe);
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize;
@ -781,30 +743,7 @@ 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
@ -843,17 +782,6 @@ 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);
@ -956,7 +884,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, fileProbe);
const result = await this._executeUpload(task, progressCb, signal, throttle);
this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
@ -965,33 +893,12 @@ 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
@ -1016,126 +923,7 @@ class UploadManager extends EventEmitter {
}
}
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) {
async _executeUpload(task, progressCb, signal, throttle) {
if (task.hoster === 'vidmoly.me' && task.username) {
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
@ -1167,10 +955,7 @@ 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 (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true;
}
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', 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);
}

24
main.js
View File

@ -731,17 +731,6 @@ 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;
@ -1301,13 +1290,6 @@ 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;
});
@ -1572,7 +1554,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
_jobLogCollector.clear();
// Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
globalThis._mhuUploadManagerRef = uploadManager;
const _progressByJob = new Map();
@ -1664,9 +1646,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
'mark-failed',
'rotation-end',
'doodstream-via-api',
'doodstream-via-web',
'suspect-reject-alt',
'suspect-reject-exhausted'
'doodstream-via-web'
]);
uploadManager.on('rot-log', (entry) => {
try {

View File

@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
"version": "3.3.66",
"version": "3.3.65",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {

View File

@ -35,62 +35,7 @@ function stubByseUploadServer() {
};
}
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 () => {
test('byse explicit "Not video file format" throws fast WITHOUT recovery polling', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
@ -109,39 +54,12 @@ test('byse "Not video file format" with probe-confirmed NON-video skips the reco
};
};
await assert.rejects(
() => 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, '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)
(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 a genuine rejection');
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on explicit rejection');
});
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {

View File

@ -98,18 +98,3 @@ 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);
});

View File

@ -1,222 +0,0 @@
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);
});
});