diff --git a/lib/hosters.js b/lib/hosters.js index cee9680..0097b27 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -600,12 +600,14 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro return result; } + 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 // up in the account shortly after — poll the list to claim it. User observed // this with 2+ GB MKV uploads that appeared as "OK" on the byse dashboard // even after our uploader gave up. - if (hosterName === 'byse.sx' && byseBaseline) { + if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) { const fileName = path.basename(filePath); const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal); if (polled) return polled; @@ -614,7 +616,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro // Doodstream: the doodapi upload POST returned no filecode (the same backend // hiccup that empties the web form). Poll the account file list by name — if // the file did register, claim its code instead of failing the upload. - if (hosterName === 'doodstream.com' && doodBaseline) { + if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) { const fileName = path.basename(filePath); const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal); if (polled) return polled; diff --git a/renderer/app.js b/renderer/app.js index a038023..81ae2dc 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -67,8 +67,8 @@ let _historySortClicked = false; // Session-specific files for the "Files" panel (resets each session) let sessionFilesData = []; +let _recentSeqCounter = 0; const recentSortState = { key: 'date', direction: 'desc' }; -let _recentSortClicked = false; const selectedRecentIds = new Set(); // Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. let _sessionDoneCount = 0; @@ -2521,7 +2521,7 @@ function maybeAddSessionFile(job) { host: job.hoster || '', link, isError: false, - order: sessionFilesData.length + order: _recentSeqCounter++ }); _sessionDoneCount++; // Drop oldest entries past the cap to keep render cost bounded. @@ -4243,10 +4243,11 @@ function renderRecentUploadsPanel() { && rows.length > _recentLastRenderedLen && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; + const wrap = tbody.closest('.recent-files-table-wrap'); + const wasAtTop = !wrap || wrap.scrollTop <= 48; + let wasAppendOnly = false; if (dateDescAppendOnly) { - // Fast path: only new rows (date desc puts newest on top) — insert them - // at the top without rebuilding the 5000-row tbody below. const added = rows.length - _recentLastRenderedLen; let html = ''; for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); @@ -4255,6 +4256,7 @@ function renderRecentUploadsPanel() { } else { tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); } + if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0; _recentLastRenderedSig = sig; _recentLastRenderedLen = rows.length; @@ -4420,14 +4422,13 @@ function setupListeners() { const th = e.target.closest('th[data-recent-sort]'); if (!th) return; const key = th.dataset.recentSort; - const defaultDir = key === 'date' ? 'desc' : 'asc'; - if (!_recentSortClicked || recentSortState.key !== key) { - _recentSortClicked = true; - recentSortState.key = key; - recentSortState.direction = defaultDir; - } else { + if (recentSortState.key === key) { recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; + } else { + recentSortState.key = key; + recentSortState.direction = key === 'date' ? 'desc' : 'asc'; } + _recentLastRenderedSig = ''; renderRecentUploadsPanel(); }); diff --git a/tests/byse-reject-recovery.test.js b/tests/byse-reject-recovery.test.js new file mode 100644 index 0000000..f33c728 --- /dev/null +++ b/tests/byse-reject-recovery.test.js @@ -0,0 +1,90 @@ +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 explicit "Not video file format" 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: 'Not video file format' }] }) } + }; + }; + + await assert.rejects( + () => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null), + (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'); +}); + +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'); +});