diff --git a/lib/file-probe.js b/lib/file-probe.js index 4a94a50..b312f6a 100644 --- a/lib/file-probe.js +++ b/lib/file-probe.js @@ -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 }, diff --git a/lib/hosters.js b/lib/hosters.js index 0097b27..17c7d3e 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -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 diff --git a/lib/upload-manager.js b/lib/upload-manager.js index f03d2c8..ff85688 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -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> (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); } diff --git a/main.js b/main.js index 0d32cc0..bd93688 100644 --- a/main.js +++ b/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; }); @@ -1560,7 +1578,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(); @@ -1652,7 +1670,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 { diff --git a/tests/byse-reject-recovery.test.js b/tests/byse-reject-recovery.test.js index f33c728..9698b54 100644 --- a/tests/byse-reject-recovery.test.js +++ b/tests/byse-reject-recovery.test.js @@ -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 () => { diff --git a/tests/file-probe.test.js b/tests/file-probe.test.js index efd3ed0..c792a52 100644 --- a/tests/file-probe.test.js +++ b/tests/file-probe.test.js @@ -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); +}); diff --git a/tests/suspect-reject-alternates.test.js b/tests/suspect-reject-alternates.test.js new file mode 100644 index 0000000..f9b02b7 --- /dev/null +++ b/tests/suspect-reject-alternates.test.js @@ -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); + }); +});