Ask a video's own page for a thumbnail the listing lost
sxyprn signs its CDN paths with an expiry, and the URLs the listing hands us have generally passed theirs. They only look alive while Cloudflare still has the bytes: add a cache-buster to one that returns 200 and it returns 404, every time, for every one tried. The newest posts are the ones nobody fetched while the URL was valid, so they are the ones that arrive as holes in the grid -- which is exactly where this was reported. Neither route can help, because both ask for the same dead address, and the right one can't be derived: the token signs the whole path, so swapping `full.jpg` for `small.jpg` or `vid` for `img` is just another 404. The post page, though, always carries a freshly signed one in og:image. So when a thumbnail has failed every way we know to ask for it, /api/poster fetches that page and reads the picture off it -- streamed, capped, and only if what comes back is HTML, since a page URL that turns out to redirect to the video must cost one buffer rather than a download. Answers are cached, including "nothing", which is the honest answer for a post that has been deleted. Finding the page is the other half. A listing item points at the Hot Tub server's proxy, which answers by redirecting to the video, so there is no page there to read -- but the item also carries the Referer the media needs, and that names the site. Its origin plus the path the proxy was going to fetch is the page a browser would open. Also fixes what this exposed: the retry ladder disarmed itself on its last step, so the failure that means "the picture is gone, not the route" was never heard by anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -494,7 +494,7 @@ App.favorites = App.favorites || {};
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||
App.videos.attachThumbnail(thumb, item.thumb);
|
||||
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
|
||||
@@ -631,7 +631,7 @@ App.feed = App.feed || {};
|
||||
</div>
|
||||
`;
|
||||
const poster = pane.querySelector('.feed-poster');
|
||||
App.videos.attachThumbnail(poster, v.thumb);
|
||||
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
||||
const slideVideo = pane.querySelector('.feed-video');
|
||||
bindTimeline(pane, slideVideo);
|
||||
bindSharedControls(pane, slideVideo, v);
|
||||
@@ -1110,7 +1110,7 @@ App.feed = App.feed || {};
|
||||
const titleText = pane.querySelector('.feed-title-text');
|
||||
if (titleText) titleText.textContent = v.title || '';
|
||||
const poster = pane.querySelector('.feed-poster');
|
||||
if (poster) App.videos.attachThumbnail(poster, v.thumb);
|
||||
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
||||
loadSlideSource(pane, v, true);
|
||||
setPipMetadata(v);
|
||||
};
|
||||
|
||||
@@ -254,6 +254,45 @@ App.videos = App.videos || {};
|
||||
].filter((step) => !!step.url);
|
||||
};
|
||||
|
||||
// Last resort, once every route to the listed picture has failed: ask the
|
||||
// page the item came from what picture *it* shows.
|
||||
//
|
||||
// A listing's thumbnail URL can be dead on arrival. sxyprn signs its CDN
|
||||
// paths with an expiry, and the URLs the server hands us have generally
|
||||
// passed theirs -- they only look alive while Cloudflare still has the
|
||||
// bytes cached, which for the newest posts it does not. The signature can't
|
||||
// be recomputed here, but the post page always carries a freshly signed
|
||||
// one, so the server fetches the page and reads it off (see /api/poster).
|
||||
//
|
||||
// One ask per page, answered from the server's cache after that, and the
|
||||
// replacement is loaded like any other thumbnail -- except that it gets no
|
||||
// second repair, so a page that keeps handing back a dead picture stops
|
||||
// being asked.
|
||||
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
|
||||
|
||||
const repairThumbnail = function(img, token) {
|
||||
const page = img.dataset.thumbPage || '';
|
||||
if (!page) return;
|
||||
let pending = posterAsked.get(page);
|
||||
if (!pending) {
|
||||
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data) => (data && usableThumbUrl(data.thumb)) || '')
|
||||
.catch(() => '');
|
||||
posterAsked.set(page, pending);
|
||||
}
|
||||
pending.then((replacement) => {
|
||||
if (!replacement || img.dataset.thumbToken !== token) return;
|
||||
// No second repair for this mount: the ladder below must not come
|
||||
// back here and ask the same page for the same picture forever.
|
||||
delete img.dataset.thumbPage;
|
||||
const proxyUrl = App.videos.buildImageProxyUrl(replacement);
|
||||
const route = imageRoutes.get(imageHostOf(replacement));
|
||||
attachRetry(img, retryPlan(replacement, proxyUrl, route), token);
|
||||
showThumbnail(img, route === IMAGE_PROXY ? proxyUrl : replacement, token);
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -263,15 +302,18 @@ App.videos = App.videos || {};
|
||||
// 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();
|
||||
// Armed even with nothing left to try: the failure of the *last* step
|
||||
// is what says the picture itself is gone, and something has to be
|
||||
// listening to hear it.
|
||||
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.
|
||||
const onError = function() {
|
||||
img._thumbRetry = null;
|
||||
const step = steps.shift();
|
||||
if (!step) return;
|
||||
// Out of routes: the picture itself is gone, not the way to it.
|
||||
if (!step) { repairThumbnail(img, token); return; }
|
||||
const go = function() {
|
||||
img._thumbRetryTimer = null;
|
||||
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
||||
@@ -455,9 +497,39 @@ App.videos = App.videos || {};
|
||||
proxyProbe.src = proxyUrl;
|
||||
};
|
||||
|
||||
// The page this item came from, on the site it came from -- where a fresh
|
||||
// picture can be read off when the listed one is dead (see
|
||||
// repairThumbnail). Empty when the item doesn't say enough to name it.
|
||||
//
|
||||
// A listing item points at the Hot Tub server's own proxy
|
||||
// (/proxy/<channel>/post/<id>.html), which answers a request by redirecting
|
||||
// to the video: there is no page there to read. The site's address is in
|
||||
// the item all the same -- it's the Referer the server says the media needs
|
||||
// -- so putting that origin in front of the path the proxy was going to
|
||||
// fetch names the page a browser would open.
|
||||
App.videos.sourcePageUrl = function(v) {
|
||||
if (!v || !v.url) return '';
|
||||
const headers = v.http_headers || {};
|
||||
const referer = headers.Referer || headers.referer || '';
|
||||
if (!referer) return '';
|
||||
try {
|
||||
const page = new URL(v.url);
|
||||
const site = new URL(referer);
|
||||
if (site.origin === page.origin) return page.href;
|
||||
const prefix = `/proxy/${v.channel}/`;
|
||||
const path = v.channel && page.pathname.startsWith(prefix) ?
|
||||
page.pathname.slice(prefix.length - 1) : page.pathname;
|
||||
return site.origin + path + page.search;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 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) {
|
||||
// racing the two the first time that host is seen. `page` is optional: the
|
||||
// item's own page, asked for a replacement if the picture turns out to be
|
||||
// gone.
|
||||
App.videos.attachThumbnail = function(img, url, page) {
|
||||
// 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) || '');
|
||||
@@ -483,6 +555,8 @@ App.videos = App.videos || {};
|
||||
// stops being able to touch this element.
|
||||
const token = String(++thumbSeq);
|
||||
img.dataset.thumbToken = token;
|
||||
if (page) img.dataset.thumbPage = page;
|
||||
else delete img.dataset.thumbPage;
|
||||
|
||||
if (route === IMAGE_PROXY) {
|
||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
||||
@@ -515,6 +589,7 @@ App.videos = App.videos || {};
|
||||
if (!img) return;
|
||||
img.dataset.thumbToken = String(++thumbSeq);
|
||||
delete img.dataset.alt;
|
||||
delete img.dataset.thumbPage;
|
||||
detachRetry(img);
|
||||
};
|
||||
|
||||
@@ -957,7 +1032,7 @@ App.videos = App.videos || {};
|
||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||
// -- would be pure waste.
|
||||
if (!(options && options.skipThumbnail)) {
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v));
|
||||
}
|
||||
return card;
|
||||
};
|
||||
@@ -1643,7 +1718,7 @@ App.videos = App.videos || {};
|
||||
if (!card || !v) return;
|
||||
unfilled.delete(i);
|
||||
const img = cardRefs(card).img;
|
||||
App.videos.attachThumbnail(img, v.thumb);
|
||||
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v));
|
||||
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||
if (img) {
|
||||
|
||||
Reference in New Issue
Block a user