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.
173 lines
7.1 KiB
JavaScript
173 lines
7.1 KiB
JavaScript
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
|
const undici = require('undici');
|
|
const _origUndiciRequest = undici.request;
|
|
undici.request = (...a) => requestRouter(...a);
|
|
delete require.cache[require.resolve('../lib/hosters')];
|
|
const hostersMod = require('../lib/hosters');
|
|
const { uploadFile } = hostersMod;
|
|
|
|
let tmpFile;
|
|
let origFetch;
|
|
before(() => {
|
|
tmpFile = path.join(os.tmpdir(), `byse-itest-${process.pid}.mkv`);
|
|
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
|
origFetch = global.fetch;
|
|
});
|
|
after(() => {
|
|
global.fetch = origFetch;
|
|
undici.request = _origUndiciRequest;
|
|
delete require.cache[require.resolve('../lib/hosters')];
|
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
});
|
|
|
|
function stubByseUploadServer() {
|
|
global.fetch = async (url) => {
|
|
if (/upload\/server/.test(String(url))) {
|
|
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.byse.sx/upload/01' }) };
|
|
}
|
|
return { status: 200, text: async () => '{"status":200}' };
|
|
};
|
|
}
|
|
|
|
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) => {
|
|
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: 'Not video file format' }] }) }
|
|
};
|
|
};
|
|
|
|
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)
|
|
);
|
|
|
|
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 () => {
|
|
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: 'RECOVERED99', 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' }) }
|
|
};
|
|
};
|
|
|
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
|
assert.strictEqual(res.file_code, 'RECOVERED99');
|
|
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
|
|
});
|