mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 04:54:34 +03:00
fix download from github on some networks
This commit is contained in:
@@ -143,10 +143,10 @@ jobs:
|
||||
- name: Compress with UPX
|
||||
if: matrix.goos == 'linux' || matrix.goos == 'windows'
|
||||
run: |
|
||||
# -9 alone (no --lzma) is ~4× faster, only ~5% larger.
|
||||
# xargs -P fans out across all CPUs.
|
||||
# Keep best/lzma for small binaries; xargs -P does them
|
||||
# in parallel across CPUs so wall time stays low.
|
||||
find build -maxdepth 1 -type f -print0 \
|
||||
| xargs -0 -n1 -P "$(nproc)" -I{} sh -c 'upx -9 "$1" || true' _ {}
|
||||
| xargs -0 -n1 -P "$(nproc)" -I{} sh -c 'upx --best --lzma "$1" || true' _ {}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+119
-141
@@ -5585,7 +5585,11 @@
|
||||
var ok = await showConfirmDialog(t('cancel_media_msg') || 'Cancel this download?', t('yes') || 'Yes', t('no') || 'No');
|
||||
if (!ok) return;
|
||||
if (!mediaInflight[msgID]) return;
|
||||
try { mediaInflight[msgID].xhr.abort(); } catch (e) { }
|
||||
try {
|
||||
var inf = mediaInflight[msgID];
|
||||
if (inf.ctrl) inf.ctrl.abort();
|
||||
else if (inf.xhr) inf.xhr.abort();
|
||||
} catch (e) { }
|
||||
delete mediaInflight[msgID];
|
||||
mediaResetCard(card);
|
||||
return;
|
||||
@@ -5657,18 +5661,12 @@
|
||||
var fname = card.getAttribute('data-fname') || '';
|
||||
var ghAvail = card.getAttribute('data-gh') === '1';
|
||||
var dnsAvail = card.getAttribute('data-dns') === '1';
|
||||
// forceSource is set when restartWith() retries after a fallback prompt
|
||||
// — in that case the user has already consented and we skip the
|
||||
// slow-only confirmation.
|
||||
var source = opts.forceSource || (ghAvail ? 'fast' : 'slow');
|
||||
if (!opts.forceSource && source === 'slow' && dnsAvail) {
|
||||
var ok = await showConfirmDialog(
|
||||
t('media_slow_only') || 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
|
||||
t('yes') || 'Yes', t('no') || 'No');
|
||||
if (!ok) {
|
||||
mediaPumpQueue();
|
||||
return;
|
||||
}
|
||||
if (!ok) { mediaPumpQueue(); return; }
|
||||
}
|
||||
var baseUrl = '/api/media/get?ch=' + encodeURIComponent(ch)
|
||||
+ '&blk=' + encodeURIComponent(blk)
|
||||
@@ -5680,121 +5678,37 @@
|
||||
var attempt = 0;
|
||||
debugLog('media: download msg=' + msgID + ' source=' + source + ' size=' + size);
|
||||
|
||||
// restartWith re-runs the download with a forced source. Pass it
|
||||
// through so the prompt isn't shown again — user consent already
|
||||
// happened in the fallback dialog.
|
||||
var ctrl = new AbortController();
|
||||
var pollTimer = null;
|
||||
var progressShownAt = 0;
|
||||
var MIN_PROGRESS_VISIBLE_MS = 350;
|
||||
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||||
function finishSlot() {
|
||||
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
|
||||
mediaPumpQueue();
|
||||
}
|
||||
function restartWith(newSource) {
|
||||
try { stopPoll(); } catch (e) { }
|
||||
try { xhr.abort(); } catch (e) { }
|
||||
stopPoll();
|
||||
try { ctrl.abort(); } catch (e) { }
|
||||
delete mediaInflight[msgID];
|
||||
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
|
||||
delete mediaProgressState[msgID];
|
||||
mediaRunDownload(domID, { forceSource: newSource });
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'blob';
|
||||
xhr.open('GET', url);
|
||||
xhr.onprogress = function (ev) {
|
||||
var total = ev.total || parseInt(size, 10) || 0;
|
||||
var loaded = ev.loaded || 0;
|
||||
mediaUpdateProgress(card, loaded, total);
|
||||
};
|
||||
var progressShownAt = 0;
|
||||
var MIN_PROGRESS_VISIBLE_MS = 350;
|
||||
async function deliverBlob() {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
var expectedCRC = parseInt(crc, 16);
|
||||
var expectedSize = parseInt(size, 10);
|
||||
// Hash + size verification — required by spec for any relay.
|
||||
if (xhr.response && !isNaN(expectedSize) && expectedSize > 0 && xhr.response.size !== expectedSize) {
|
||||
await mediaForgetCache(card);
|
||||
if (source === 'fast') { handleFastFailure(t('media_size_mismatch') || 'Size mismatch'); return; }
|
||||
mediaShowError(card, t('media_size_mismatch') || 'Size mismatch');
|
||||
return;
|
||||
}
|
||||
if (!isNaN(expectedCRC) && expectedCRC > 0) {
|
||||
try {
|
||||
var got = await blobCRC32(xhr.response);
|
||||
if (got !== expectedCRC) {
|
||||
await mediaForgetCache(card);
|
||||
if (source === 'fast') { handleFastFailure(t('media_hash_mismatch') || 'Content hash mismatch'); return; }
|
||||
mediaShowError(card, t('media_hash_mismatch') || 'Content hash mismatch');
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
var blobURL = URL.createObjectURL(xhr.response);
|
||||
mediaBlobURLs[msgID] = blobURL;
|
||||
mediaBlobs[msgID] = { blob: xhr.response, url: blobURL, mime: xhr.getResponseHeader('Content-Type') || '' };
|
||||
mediaShowBlob(card, blobURL);
|
||||
mediaPersistBlob(msgID, card, xhr.response, mediaBlobs[msgID].mime);
|
||||
} else {
|
||||
// GitHub rate limit gets its own popup with the reset time so
|
||||
// the user knows it's temporary and how long to wait.
|
||||
if (source === 'fast' && xhr.status === 429) {
|
||||
var resetMin = parseInt(xhr.getResponseHeader('X-Relay-Reset-Min') || '0', 10);
|
||||
await handleRateLimit(resetMin);
|
||||
return;
|
||||
}
|
||||
if (source === 'fast') { handleFastFailure(xhr.statusText || ('HTTP ' + xhr.status), xhr.status); return; }
|
||||
mediaShowError(card, xhr.statusText || ('HTTP ' + xhr.status));
|
||||
}
|
||||
}
|
||||
async function handleRateLimit(resetMin) {
|
||||
var minutes = (resetMin && resetMin > 0) ? resetMin : '?';
|
||||
var msg = (t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes);
|
||||
showToast(msg);
|
||||
// Always try DNS as the fallback — even if the manifest
|
||||
// didn't advertise it (the file might still be reachable
|
||||
// via DNS even when the flag says no). If DNS truly can't
|
||||
// serve it, the slow path will surface its own error.
|
||||
showToast((t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min — using slow path.').replace('{n}', minutes));
|
||||
restartWith('slow');
|
||||
}
|
||||
function finishSlot() {
|
||||
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
|
||||
mediaPumpQueue();
|
||||
}
|
||||
xhr.onload = function () {
|
||||
delete mediaInflight[msgID];
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
var totalSize = (xhr.response && xhr.response.size) ? xhr.response.size
|
||||
: (parseInt(card.getAttribute('data-size'), 10) || 0);
|
||||
if (totalSize > 0) mediaUpdateProgress(card, totalSize, totalSize);
|
||||
debugLog('media: ok msg=' + msgID + ' source=' + source + ' served-by=' + (xhr.getResponseHeader('X-Cache') || '?'));
|
||||
}
|
||||
var elapsed = Date.now() - progressShownAt;
|
||||
if (progressShownAt > 0 && elapsed < MIN_PROGRESS_VISIBLE_MS) {
|
||||
setTimeout(function () { deliverBlob().finally(finishSlot); }, MIN_PROGRESS_VISIBLE_MS - elapsed);
|
||||
} else {
|
||||
deliverBlob().finally(finishSlot);
|
||||
}
|
||||
};
|
||||
async function handleFastFailure(reason, status) {
|
||||
attempt++;
|
||||
// 404 = not in repo, 429 = rate-limited. Both are terminal
|
||||
// for the fast path — retrying GitHub won't help, and we
|
||||
// don't want to burn the 60/h API budget. Skip retries and
|
||||
// fall straight through to the DNS path if available.
|
||||
var skipRetry = (status === 404 || status === 429);
|
||||
if (!skipRetry && attempt < GH_MAX_RETRIES) {
|
||||
await new Promise(function (r) { setTimeout(r, GH_RETRY_DELAY_MS); });
|
||||
var retry = new XMLHttpRequest();
|
||||
retry.responseType = 'blob';
|
||||
retry.open('GET', url);
|
||||
retry.onprogress = xhr.onprogress;
|
||||
retry.onload = xhr.onload;
|
||||
retry.onerror = xhr.onerror;
|
||||
retry.onabort = xhr.onabort;
|
||||
xhr = retry;
|
||||
mediaInflight[msgID] = { xhr: xhr };
|
||||
xhr.send();
|
||||
runOnce();
|
||||
return;
|
||||
}
|
||||
// 404 / 429: auto-fallback to DNS without a prompt — the
|
||||
// fast path is terminal here, asking "do you want slow?"
|
||||
// adds friction. Try DNS regardless of dnsAvail flag, since
|
||||
// the file might still come through that path.
|
||||
if (skipRetry && source === 'fast') {
|
||||
var note = (status === 404)
|
||||
? (t('media_relay_404_fallback') || 'Not in fast relay yet — switching to DNS')
|
||||
@@ -5803,33 +5717,109 @@
|
||||
restartWith('slow');
|
||||
return;
|
||||
}
|
||||
// Other failures — ask the user before going to the slow path.
|
||||
if (source === 'fast' && dnsAvail) {
|
||||
var ok = await showConfirmDialog(
|
||||
var ok2 = await showConfirmDialog(
|
||||
t('media_relay_fallback') || "Fast relay failed. Try the slow DNS path? Note: this can be very slow.",
|
||||
t('yes') || 'Yes', t('no') || 'No');
|
||||
if (ok) {
|
||||
restartWith('slow');
|
||||
return;
|
||||
}
|
||||
if (ok2) { restartWith('slow'); return; }
|
||||
}
|
||||
mediaShowError(card, reason || (t('media_failed') || 'Download failed'));
|
||||
finishSlot();
|
||||
}
|
||||
xhr.onerror = function () {
|
||||
delete mediaInflight[msgID];
|
||||
if (source === 'fast') { handleFastFailure('network error'); return; }
|
||||
mediaShowError(card, 'network error');
|
||||
finishSlot();
|
||||
};
|
||||
xhr.onabort = function () {
|
||||
delete mediaInflight[msgID];
|
||||
finishSlot();
|
||||
};
|
||||
// Reset progress state for this download — both byte and block
|
||||
// counters live in mediaProgressState[msgID] and only ever go up.
|
||||
// For the fast (relay) path the block counter is meaningless, so
|
||||
// blocks=0 and the text omits the K/N suffix.
|
||||
|
||||
async function deliverBlob(blob, headers) {
|
||||
var expectedCRC = parseInt(crc, 16);
|
||||
var expectedSize = parseInt(size, 10);
|
||||
if (blob && !isNaN(expectedSize) && expectedSize > 0 && blob.size !== expectedSize) {
|
||||
await mediaForgetCache(card);
|
||||
if (source === 'fast') { handleFastFailure(t('media_size_mismatch') || 'Size mismatch'); return; }
|
||||
mediaShowError(card, t('media_size_mismatch') || 'Size mismatch');
|
||||
return;
|
||||
}
|
||||
if (!isNaN(expectedCRC) && expectedCRC > 0) {
|
||||
try {
|
||||
var got = await blobCRC32(blob);
|
||||
if (got !== expectedCRC) {
|
||||
await mediaForgetCache(card);
|
||||
if (source === 'fast') { handleFastFailure(t('media_hash_mismatch') || 'Content hash mismatch'); return; }
|
||||
mediaShowError(card, t('media_hash_mismatch') || 'Content hash mismatch');
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
var mime = (headers && headers.get('Content-Type')) || '';
|
||||
var blobURL = URL.createObjectURL(blob);
|
||||
mediaBlobURLs[msgID] = blobURL;
|
||||
mediaBlobs[msgID] = { blob: blob, url: blobURL, mime: mime };
|
||||
mediaShowBlob(card, blobURL);
|
||||
mediaPersistBlob(msgID, card, blob, mime);
|
||||
}
|
||||
|
||||
// fetch + manual reader so we can finalise the moment we have
|
||||
// Content-Length bytes — some censoring proxies hold the
|
||||
// connection open after sending the body, which makes XHR /
|
||||
// resp.blob() hang at 100 %. Cancelling the reader breaks us
|
||||
// out of that wait.
|
||||
async function runOnce() {
|
||||
try {
|
||||
var resp = await fetch(url, { signal: ctrl.signal, cache: 'no-store' });
|
||||
if (!resp.ok) {
|
||||
stopPoll();
|
||||
delete mediaInflight[msgID];
|
||||
if (source === 'fast' && resp.status === 429) {
|
||||
var resetMin = parseInt(resp.headers.get('X-Relay-Reset-Min') || '0', 10);
|
||||
await handleRateLimit(resetMin);
|
||||
return;
|
||||
}
|
||||
if (source === 'fast') { handleFastFailure(resp.statusText || ('HTTP ' + resp.status), resp.status); return; }
|
||||
mediaShowError(card, resp.statusText || ('HTTP ' + resp.status));
|
||||
finishSlot();
|
||||
return;
|
||||
}
|
||||
var total = parseInt(resp.headers.get('Content-Length') || '0', 10) || (parseInt(size, 10) || 0);
|
||||
var blob;
|
||||
if (!resp.body || !resp.body.getReader) {
|
||||
// Old WebView fallback — accept the original hang risk.
|
||||
blob = await resp.blob();
|
||||
} else {
|
||||
var reader = resp.body.getReader();
|
||||
var chunks = [];
|
||||
var received = 0;
|
||||
while (true) {
|
||||
var step = await reader.read();
|
||||
if (step.done) break;
|
||||
chunks.push(step.value);
|
||||
received += step.value.byteLength;
|
||||
mediaUpdateProgress(card, received, total);
|
||||
if (total > 0 && received >= total) {
|
||||
try { await reader.cancel(); } catch (e) { }
|
||||
break;
|
||||
}
|
||||
}
|
||||
blob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'application/octet-stream' });
|
||||
}
|
||||
stopPoll();
|
||||
delete mediaInflight[msgID];
|
||||
var totalSize = blob.size || (parseInt(card.getAttribute('data-size'), 10) || 0);
|
||||
if (totalSize > 0) mediaUpdateProgress(card, totalSize, totalSize);
|
||||
debugLog('media: ok msg=' + msgID + ' source=' + source + ' served-by=' + (resp.headers.get('X-Cache') || '?'));
|
||||
var elapsed = Date.now() - progressShownAt;
|
||||
var run = function () { deliverBlob(blob, resp.headers).finally(finishSlot); };
|
||||
if (progressShownAt > 0 && elapsed < MIN_PROGRESS_VISIBLE_MS) {
|
||||
setTimeout(run, MIN_PROGRESS_VISIBLE_MS - elapsed);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
} catch (e) {
|
||||
stopPoll();
|
||||
delete mediaInflight[msgID];
|
||||
if (e && e.name === 'AbortError') { finishSlot(); return; }
|
||||
if (source === 'fast') { handleFastFailure((e && e.message) || 'network error'); return; }
|
||||
mediaShowError(card, (e && e.message) || 'network error');
|
||||
finishSlot();
|
||||
}
|
||||
}
|
||||
|
||||
mediaProgressState[msgID] = {
|
||||
loaded: 0,
|
||||
total: parseInt(size, 10) || 0,
|
||||
@@ -5837,35 +5827,23 @@
|
||||
blocks: source === 'slow' ? (parseInt(blk, 10) || 0) : 0
|
||||
};
|
||||
|
||||
// Poll the server's per-download block counter so the user sees real
|
||||
// block-level progress (the Go side fetches one DNS block at a time
|
||||
// but io.Copy buffers those into bigger HTTP chunks, hence the
|
||||
// "static then jumps" feel without polling).
|
||||
var pollUrl = '/api/media/progress?ch=' + encodeURIComponent(ch)
|
||||
+ '&blk=' + encodeURIComponent(blk)
|
||||
+ '&crc=' + encodeURIComponent(crc);
|
||||
var pollTimer = setInterval(async function () {
|
||||
pollTimer = setInterval(async function () {
|
||||
try {
|
||||
var pr = await fetch(pollUrl);
|
||||
if (!pr.ok) return;
|
||||
var pj = await pr.json();
|
||||
// Ignore inactive/zero responses — happens before the server
|
||||
// registers the download or after it finishes. Don't let those
|
||||
// overwrite the live counter and cause the UI to bounce back.
|
||||
if (pj.active === false) return;
|
||||
mediaApplyBlockProgress(card, pj.completed | 0, pj.total | 0);
|
||||
} catch (e) { }
|
||||
}, 500);
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
||||
var origOnload = xhr.onload, origOnerror = xhr.onerror, origOnabort = xhr.onabort;
|
||||
xhr.onload = function () { stopPoll(); origOnload && origOnload.apply(this, arguments); };
|
||||
xhr.onerror = function () { stopPoll(); origOnerror && origOnerror.apply(this, arguments); };
|
||||
xhr.onabort = function () { stopPoll(); origOnabort && origOnabort.apply(this, arguments); };
|
||||
|
||||
mediaInflight[msgID] = { xhr: xhr };
|
||||
mediaInflight[msgID] = { ctrl: ctrl };
|
||||
mediaShowProgress(card);
|
||||
progressShownAt = Date.now();
|
||||
requestAnimationFrame(function () { xhr.send(); });
|
||||
runOnce();
|
||||
}
|
||||
|
||||
// mediaProgressState is the per-download counter. Both xhr.onprogress
|
||||
|
||||
Reference in New Issue
Block a user