Keep one junk thumbnail from taking the page down with it

sxyprn's "latest" listing sends items whose thumb is the bare string
"https:". Resolved against the page -- which is what new URL(url, location)
does with anything that isn't absolute -- that becomes our own address, so
the card raced our own HTML as if it were a picture, pinned our origin to
the proxy for the rest of the session, and asked /api/image to fetch
"https:" (a 400, every time). An address that doesn't stand on its own is
no thumbnail at all, and is now treated as one: no src, no race, no
request, and the card keeps its placeholder.

While in here: a thumbnail that failed had exactly one more chance, the
proxy, and images already on the proxy route had none at all -- so one
refused connection left a card empty for as long as it stayed mounted. A
failure now walks a short ladder instead: the other route, then both again
after a pause. Bounded and backed off, and dropped the moment the card is
rebound, so a page of genuinely dead images costs a handful of requests
rather than a storm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-18 19:31:05 +00:00
parent 191c81e55e
commit b478c51551
2 changed files with 287 additions and 41 deletions

View File

@@ -114,10 +114,36 @@ App.videos = App.videos || {};
return `${secs}`;
};
App.videos.buildImageProxyUrl = function(imageUrl) {
if (!imageUrl) return '';
// A thumbnail URL we can actually fetch, or '' if the provider sent
// something we can't use.
//
// Providers do send junk: sxyprn's "latest" listing carries items whose
// `thumb` is the bare string "https:". Resolved against the page -- which
// is what `new URL(url, location)` does with anything that isn't absolute
// -- that becomes *our own* address, and the card then races our own HTML
// as if it were a picture, pins our origin to the proxy for the rest of
// the session, and asks /api/image to fetch "https:" (a 400, every time).
// So an address that doesn't stand on its own is treated as no thumbnail
// at all, which is what it is.
const usableThumbUrl = function(url) {
if (!url || typeof url !== 'string') return '';
let parsed;
try {
return `/api/image?url=${encodeURIComponent(imageUrl)}`;
parsed = new URL(url); // no base: relative input throws
} catch (err) {
return '';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
// The provider's own spelling, not the parsed one: a signed URL is only
// valid as it was written, and normalising it can change the bytes.
return url;
};
App.videos.buildImageProxyUrl = function(imageUrl) {
const target = usableThumbUrl(imageUrl);
if (!target) return '';
try {
return `/api/image?url=${encodeURIComponent(target)}`;
} catch (err) {
return '';
}
@@ -177,10 +203,11 @@ App.videos = App.videos || {};
// provider while the host is still unknown -- the optimistic route, and the
// one a race starts on anyway.
App.videos.thumbnailUrl = function(url) {
if (!url) return '';
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
? (App.videos.buildImageProxyUrl(url) || url)
: url;
const target = usableThumbUrl(url);
if (!target) return '';
return imageRoutes.get(imageHostOf(target)) === IMAGE_PROXY
? (App.videos.buildImageProxyUrl(target) || target)
: target;
};
// A src-less <img> counts as "unavailable", and the browser paints its alt
@@ -203,29 +230,76 @@ App.videos = App.videos || {};
img.src = url;
};
// Last resort on a route that normally works: one expired or missing image
// shouldn't be left broken just because its host is fine in general.
const attachProxyFallback = function(img, proxyUrl, token) {
if (!proxyUrl) return;
// What a thumbnail that fails is given next, in order.
//
// A failure used to mean one more attempt -- the proxy -- and then an empty
// box for as long as the card stayed mounted. That is one blip away from a
// grid with holes in it: a refused connection, our own server busy for a
// moment, an origin that drops one request in twenty, and that card is
// simply blank until the reader happens to scroll it out of the window and
// back. So a failure now walks a short ladder instead: the other route
// first (most failures are one route's fault, not the picture's), then both
// again after a pause. Bounded and backed off, so a page of genuinely dead
// images costs a handful of requests rather than a storm, and abandoned the
// moment the card is rebound.
const RETRY_PAUSE_MS = 900;
const retryPlan = function(directUrl, proxyUrl, route) {
const own = route === IMAGE_PROXY ? proxyUrl : directUrl;
const other = route === IMAGE_PROXY ? directUrl : proxyUrl;
return [
{ url: other, delay: 0 },
{ url: own, delay: RETRY_PAUSE_MS },
{ url: other, delay: RETRY_PAUSE_MS * 3 }
].filter((step) => !!step.url);
};
// Arms `img` with the steps to take if what it is showing fails to load.
const attachRetry = function(img, plan, token) {
// Checked here too, not just in showThumbnail: this *replaces* whatever
// fallback the image currently has, so a call arriving for a generation
// the element has moved past would take away the live one and leave a
// dead one -- the recycled card's thumbnail would then have no fallback
// at all if it failed.
if (token !== undefined && img.dataset.thumbToken !== token) return;
// the image is currently armed with, so a call arriving for a generation
// the element has moved past would take away the live plan and leave a
// dead one -- the recycled card's thumbnail would then have nothing
// behind it if it failed.
if (!img || (token !== undefined && img.dataset.thumbToken !== token)) return;
detachRetry(img);
if (!plan || !plan.length) return;
const steps = plan.slice();
// Held on the element so detachThumbnail can take it off again. On the
// happy path it never fires and `once` never collects it, so a pooled
// image would otherwise accumulate one closure per mount it has served.
detachProxyFallback(img);
const onError = () => { showThumbnail(img, proxyUrl, token); };
img._thumbFallback = onError;
const onError = function() {
img._thumbRetry = null;
const step = steps.shift();
if (!step) return;
const go = function() {
img._thumbRetryTimer = null;
if (token !== undefined && img.dataset.thumbToken !== token) return;
// Re-arm before the src lands, so a step that fails immediately
// (a cached refusal) still hands on to the next one.
attachRetry(img, steps, token);
// The same URL assigned to the same element is not a new src,
// and a browser that sees no change starts no request -- so a
// retry of the route we're already on has to clear it first.
if (img.getAttribute('src') === step.url) img.removeAttribute('src');
showThumbnail(img, step.url, token);
};
if (step.delay > 0) img._thumbRetryTimer = setTimeout(go, step.delay);
else go();
};
img._thumbRetry = onError;
img.addEventListener('error', onError, { once: true });
};
const detachProxyFallback = function(img) {
if (img && img._thumbFallback) {
img.removeEventListener('error', img._thumbFallback);
img._thumbFallback = null;
const detachRetry = function(img) {
if (!img) return;
if (img._thumbRetry) {
img.removeEventListener('error', img._thumbRetry);
img._thumbRetry = null;
}
if (img._thumbRetryTimer) {
clearTimeout(img._thumbRetryTimer);
img._thumbRetryTimer = null;
}
};
@@ -238,12 +312,9 @@ App.videos = App.videos || {};
if (!waiting) return;
imageWaiting.delete(host);
waiting.forEach((entry) => {
if (route === IMAGE_PROXY) {
showThumbnail(entry.img, entry.proxyUrl, entry.token);
return;
}
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
showThumbnail(entry.img, entry.directUrl, entry.token);
attachRetry(entry.img, retryPlan(entry.directUrl, entry.proxyUrl, route), entry.token);
showThumbnail(entry.img,
route === IMAGE_PROXY ? entry.proxyUrl : entry.directUrl, entry.token);
});
};
@@ -303,12 +374,9 @@ App.videos = App.videos || {};
const giveUp = function() {
if (shown) return;
shown = true;
if (imageRoutes.get(host) === IMAGE_PROXY) {
showThumbnail(img, proxyUrl, token);
return;
}
attachProxyFallback(img, proxyUrl, token);
showThumbnail(img, directUrl, token);
const route = imageRoutes.get(host);
attachRetry(img, retryPlan(directUrl, proxyUrl, route), token);
showThumbnail(img, route === IMAGE_PROXY ? proxyUrl : directUrl, token);
};
const decide = function() {
@@ -390,7 +458,9 @@ App.videos = App.videos || {};
// Points `img` at `url` by whichever route is known to work for its host,
// racing the two the first time that host is seen.
App.videos.attachThumbnail = function(img, url) {
const directUrl = url || (img && img.dataset.thumb) || '';
// An address that isn't one is the same thing as no thumbnail: the card
// keeps its placeholder rather than chasing it. See usableThumbUrl.
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
if (!img) return;
// Held back until there is an image to caption -- see showThumbnail. An
// item with no thumbnail keeps an empty alt: the card's own title sits
@@ -415,6 +485,7 @@ App.videos = App.videos || {};
img.dataset.thumbToken = token;
if (route === IMAGE_PROXY) {
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
showThumbnail(img, proxyUrl || directUrl, token);
return;
}
@@ -429,8 +500,8 @@ App.videos = App.videos || {};
}
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
// Known good, or nothing to race against: take the provider and keep
// the proxy as this image's own fallback.
attachProxyFallback(img, proxyUrl, token);
// the proxy behind it.
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_DIRECT), token);
showThumbnail(img, directUrl, token);
return;
}
@@ -438,13 +509,13 @@ App.videos = App.videos || {};
};
// Voids whatever is still in flight for this element's thumbnail. Its
// generation moves on, so a race that settles later, or a proxy fallback
// that fires later, finds a token that no longer matches and does nothing.
// generation moves on, so a race that settles later, or a retry that fires
// later, finds a token that no longer matches and does nothing.
App.videos.detachThumbnail = function(img) {
if (!img) return;
img.dataset.thumbToken = String(++thumbSeq);
delete img.dataset.alt;
detachProxyFallback(img);
detachRetry(img);
};
// Each channel in a group sends back a different number of videos per