Compare commits

..

No commits in common. "f81f86431475e606af438234ef66957ffdc176d3" and "6f8c2dcb3859b968b68cccf4883b480741cfa0b5" have entirely different histories.

6 changed files with 14 additions and 164 deletions

View File

@ -325,15 +325,11 @@ async function apiGet(url, signal) {
try { try {
data = JSON.parse(text); data = JSON.parse(text);
} catch { } catch {
const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`); throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
if (res.status >= 500) err.transientNetwork = true;
throw err;
} }
if (data.status && [401, 403, 429, 500].includes(data.status)) { if (data.status && [401, 403, 429, 500].includes(data.status)) {
const err = new Error(data.msg || data.message || JSON.stringify(data)); throw new Error(data.msg || data.message || JSON.stringify(data));
if (data.status === 500) err.transientNetwork = true;
throw err;
} }
return data; return data;
} finally { } finally {
@ -346,7 +342,6 @@ async function apiGet(url, signal) {
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
let lastMessage = ''; let lastMessage = '';
let lastTransient = false;
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
for (const endpoint of hosterConfig.serverEndpoints) { for (const endpoint of hosterConfig.serverEndpoints) {
@ -366,7 +361,6 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
} catch (err) { } catch (err) {
if (err.name === 'AbortError') throw err; if (err.name === 'AbortError') throw err;
if (err.message) lastMessage = err.message; if (err.message) lastMessage = err.message;
if (err.transientNetwork === true) lastTransient = true;
} }
} }
@ -400,7 +394,6 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
// Genuine auth failures (invalid key / unauthorized / forbidden) make // Genuine auth failures (invalid key / unauthorized / forbidden) make
// shouldRetryServerLookup return false and stay classified as account errors. // shouldRetryServerLookup return false and stay classified as account errors.
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true; if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
if (lastTransient) e.transientNetwork = true;
throw e; throw e;
} }
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
@ -411,7 +404,7 @@ async function _fetchByseFileList(apiKey, signal) {
// to match the upload we just did against what the server has. The API // to match the upload we just did against what the server has. The API
// shape is typical XFS: { status, msg, result: { files: [...] } } or // shape is typical XFS: { status, msg, result: { files: [...] } } or
// { status, msg, files: [...] }. // { status, msg, files: [...] }.
const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`; const url = `https://api.byse.sx/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
try { try {
const { body, statusCode } = await request(url, { const { body, statusCode } = await request(url, {
method: 'GET', signal, method: 'GET', signal,
@ -580,11 +573,9 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
payload = rawBody ? JSON.parse(rawBody) : {}; payload = rawBody ? JSON.parse(rawBody) : {};
} catch { } catch {
const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : '';
const err = new Error( throw new Error(
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
); );
if (statusCode >= 500) err.transientNetwork = true;
throw err;
} }
// Normalize valid-but-not-object JSON (JSON.parse('null') → null; // Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this // JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
@ -598,19 +589,15 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
} }
if (statusCode < 200 || statusCode >= 300) { if (statusCode < 200 || statusCode >= 300) {
const err = new Error( throw new Error(
payload.msg payload.msg
|| payload.message || payload.message
|| `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})`
); );
if (statusCode >= 500) err.transientNetwork = true;
throw err;
} }
if (payload.status && [401, 403, 429, 500].includes(payload.status)) { if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
const err = new Error(payload.msg || payload.message || JSON.stringify(payload)); throw new Error(payload.msg || payload.message || JSON.stringify(payload));
if (payload.status === 500) err.transientNetwork = true;
throw err;
} }
let result = null; let result = null;

View File

@ -131,7 +131,6 @@ class UploadManager extends EventEmitter {
// which takes priority in _shouldSkipRetryOnAccountError. // which takes priority in _shouldSkipRetryOnAccountError.
_isFileRejectedError(err) { _isFileRejectedError(err) {
if (!err) return false; if (!err) return false;
if (err.transientNetwork === true) return false;
if (err.accountError === true) return false; // explicit account-level wins if (err.accountError === true) return false; // explicit account-level wins
if (err.fileRejected === true) return true; if (err.fileRejected === true) return true;
if (!err.message) return false; if (!err.message) return false;
@ -162,9 +161,7 @@ class UploadManager extends EventEmitter {
// out for this file without blacklisting the account, so other jobs in the // out for this file without blacklisting the account, so other jobs in the
// batch still get a fresh chance on it. // batch still get a fresh chance on it.
_isTransientNetworkError(err) { _isTransientNetworkError(err) {
if (!err) return false; if (!err || !err.message) return false;
if (err.transientNetwork === true) return true;
if (!err.message) return false;
const m = String(err.message); const m = String(err.message);
const TRANSIENT = [ const TRANSIENT = [
/ENOTFOUND/i, /ENOTFOUND/i,
@ -180,11 +177,7 @@ class UploadManager extends EventEmitter {
/dns (lookup|error|failed)/i, /dns (lookup|error|failed)/i,
/getaddrinfo/i, /getaddrinfo/i,
/fetch failed/i, /fetch failed/i,
/\bconnect (ETIMEDOUT|ECONN)/i, /\bconnect (ETIMEDOUT|ECONN)/i
/HTTP 5\d\d\b/i,
/Bad Gateway/i,
/Service Unavailable/i,
/Gateway Time-?out/i
]; ];
return TRANSIENT.some(p => p.test(m)); return TRANSIENT.some(p => p.test(m));
} }
@ -195,7 +188,6 @@ class UploadManager extends EventEmitter {
// or out of quota. // or out of quota.
_shouldSkipRetryOnAccountError(err) { _shouldSkipRetryOnAccountError(err) {
if (!err) return false; if (!err) return false;
if (err.transientNetwork === true) return false;
// Explicit account-level flag from hoster parsers — highest priority. // Explicit account-level flag from hoster parsers — highest priority.
if (err.accountError === true) return true; if (err.accountError === true) return true;
if (!err.message) return false; if (!err.message) return false;
@ -990,7 +982,6 @@ class UploadManager extends EventEmitter {
if (signal.aborted || this.stopAfterActive) break; if (signal.aborted || this.stopAfterActive) break;
if (this._isFileRejectedError(err)) break; if (this._isFileRejectedError(err)) break;
if (this._isHosterTransientError(err)) break; if (this._isHosterTransientError(err)) break;
if (this._isTransientNetworkError(err)) break;
if (attempt >= maxAttempts) break; if (attempt >= maxAttempts) break;
} }
} }

View File

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

View File

@ -40,7 +40,7 @@ test('byse "Not video file format" (suspect) DOES poll recovery and claims the a
let listCalls = 0; let listCalls = 0;
requestRouter = async (url, opts) => { requestRouter = async (url, opts) => {
const u = String(url); const u = String(url);
if (/\/file\/list/.test(u)) { if (/\/api\/file\/list/.test(u)) {
listCalls++; listCalls++;
const body = listCalls === 1 const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}' ? '{"status":200,"result":{"files":[]}}'
@ -68,7 +68,7 @@ test('byse "Not video file format" with empty poll throws err.suspectReject so r
let listCalls = 0; let listCalls = 0;
requestRouter = async (url, opts) => { requestRouter = async (url, opts) => {
const u = String(url); const u = String(url);
if (/\/file\/list/.test(u)) { if (/\/api\/file\/list/.test(u)) {
listCalls++; listCalls++;
if (listCalls >= 2) abort.abort(); if (listCalls >= 2) abort.abort();
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
@ -95,7 +95,7 @@ test('byse "Not video file format" with probe-confirmed NON-video skips the reco
let listCalls = 0; let listCalls = 0;
requestRouter = async (url, opts) => { requestRouter = async (url, opts) => {
const u = String(url); const u = String(url);
if (/\/file\/list/.test(u)) { if (/\/api\/file\/list/.test(u)) {
listCalls++; listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
} }
@ -122,7 +122,7 @@ test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery pol
let listCalls = 0; let listCalls = 0;
requestRouter = async (url, opts) => { requestRouter = async (url, opts) => {
const u = String(url); const u = String(url);
if (/\/file\/list/.test(u)) { if (/\/api\/file\/list/.test(u)) {
listCalls++; listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } }; return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
} }
@ -149,7 +149,7 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn
let listCalls = 0; let listCalls = 0;
requestRouter = async (url, opts) => { requestRouter = async (url, opts) => {
const u = String(url); const u = String(url);
if (/\/file\/list/.test(u)) { if (/\/api\/file\/list/.test(u)) {
listCalls++; listCalls++;
const body = listCalls === 1 const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}' ? '{"status":200,"result":{"files":[]}}'
@ -170,65 +170,3 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn
assert.strictEqual(res.file_code, 'RECOVERED99'); assert.strictEqual(res.file_code, 'RECOVERED99');
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection'); assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
}); });
function stubBysePost(response) {
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
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 response();
};
}
test('byse upload POST 502 (HTML gateway body) is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 502,
headers: { 'content-type': 'text/html' },
body: { text: async () => '<!doctype html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>' }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true && /kein JSON \(HTTP 502\)/.test(err.message)
);
});
test('byse upload POST non-2xx JSON 503 is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 503,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 503, msg: 'Service Unavailable' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
});
test('byse upload POST 2xx envelope {status:500} is transient; {status:403} stays account-level', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 500, msg: 'Internal Server Error' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 403, msg: 'Forbidden' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork !== true
);
});

View File

@ -250,31 +250,4 @@ describe('suspect-reject alternate accounts', () => {
assert.equal(summary.failed, 1); assert.equal(summary.failed, 1);
assert.equal(mockUploadFile.mock.calls.length, 1); assert.equal(mockUploadFile.mock.calls.length, 1);
}); });
it('a transient 5xx (byse 502) retries the SAME account and fails clean — no blacklist, no failover cascade', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { retries: 2 });
mgr._sleep = async () => {};
mockUploadFile.mock.mockImplementation(async () => {
const e = new Error('Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html>');
e.transientNetwork = true;
throw e;
});
let accountFailed = 0;
mgr.on('account-failed', () => { accountFailed++; });
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(accountFailed, 0, 'a transient 502 must never emit account-failed');
assert.equal(mgr.getFailedAccountKeys().length, 0, 'no account blacklisted on a transient 502');
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.ok(keys.length >= 2, 'the 502 is retried on the same account');
assert.ok(keys.every(k => k === 'key1'), 'every attempt stays on the primary — no cascade to key2/key3');
});
}); });

View File

@ -847,45 +847,6 @@ describe('UploadManager', () => {
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false); assert.equal(mgr._shouldSkipRetryOnAccountError(err), false);
}); });
it('transientNetwork flag is recognised even with an empty/absent message', () => {
const mgr = new UploadManager({});
const flagged = new Error('');
flagged.transientNetwork = true;
assert.equal(mgr._isTransientNetworkError(flagged), true, 'flag must win before the empty-message guard');
assert.equal(mgr._isFileRejectedError(flagged), false);
assert.equal(mgr._isHosterTransientError(flagged), false);
assert.equal(mgr._shouldSkipRetryOnAccountError(flagged), false);
const flaggedHtml = new Error('Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html> forbidden duplicate');
flaggedHtml.transientNetwork = true;
assert.equal(mgr._isTransientNetworkError(flaggedHtml), true);
assert.equal(mgr._shouldSkipRetryOnAccountError(flaggedHtml), false, 'flag overrides any account-keyword in the 502 HTML snippet');
assert.equal(mgr._isFileRejectedError(flaggedHtml), false, 'flag overrides any rejection-keyword in the 502 HTML snippet');
});
it('5xx / gateway errors classify transient by message (defensive fallback), 4xx stay account-level', () => {
const mgr = new UploadManager({});
const transient = [
'Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html>',
'Upload fehlgeschlagen (HTTP 503, text/html)',
'HTTP 504 Gateway Time-out',
'Bad Gateway',
'Service Unavailable'
];
for (const msg of transient) {
assert.equal(mgr._isTransientNetworkError(new Error(msg)), true, `should be transient: ${msg}`);
}
const accountLevel = [
'Upload fehlgeschlagen (HTTP 429, application/json)',
'HTTP 403 Forbidden',
'HTTP 401 Unauthorized'
];
for (const msg of accountLevel) {
assert.equal(mgr._isTransientNetworkError(new Error(msg)), false, `must NOT be transient: ${msg}`);
assert.equal(mgr._shouldSkipRetryOnAccountError(new Error(msg)), true, `must stay account-level: ${msg}`);
}
});
it('hoster-transient regex fallback catches wrapped doodstream empty-form errors', () => { it('hoster-transient regex fallback catches wrapped doodstream empty-form errors', () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
const cases = [ const cases = [