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:
Simon
2026-09-21 15:47:47 +00:00
parent a795442634
commit 051dae98cb
5 changed files with 344 additions and 13 deletions

View File

@@ -450,6 +450,104 @@ def resolve_video():
return jsonify(view_of(info))
# The picture a page says it has, in the order worth trusting: the card the
# page wants shared, then the one it declares to search engines, then the still
# its own player shows before playing.
_POSTER_META_RE = re.compile(
r'''<meta[^>]+(?:property|name|itemprop)\s*=\s*["']?'''
r'''(og:image(?::url)?|twitter:image(?::src)?|thumbnailUrl)["']?[^>]*>''', re.I)
_POSTER_CONTENT_RE = re.compile(r'''content\s*=\s*["']([^"']+)["']''', re.I)
_POSTER_VIDEO_RE = re.compile(r'''<video[^>]+poster\s*=\s*["']([^"']+)["']''', re.I)
# Everything worth having is in the head; a post page's comment section is not.
_POSTER_SCAN_BYTES = 256 * 1024
POSTER_CACHE_TTL = 600
_poster_cache = {}
_poster_cache_lock = threading.Lock()
def _poster_from_html(html, base_url):
"""The first usable image URL declared by `html`, absolute, or None."""
candidates = []
for match in _POSTER_META_RE.finditer(html):
content = _POSTER_CONTENT_RE.search(match.group(0))
if content:
candidates.append((match.group(1).lower(), content.group(1)))
ranked = []
for key in ('og:image', 'og:image:url', 'twitter:image', 'twitter:image:src', 'thumbnailurl'):
ranked += [url for name, url in candidates if name == key]
video_poster = _POSTER_VIDEO_RE.search(html)
if video_poster:
ranked.append(video_poster.group(1))
for url in ranked:
absolute = urljoin(base_url, url.strip())
parsed = urllib.parse.urlparse(absolute)
if parsed.scheme in ('http', 'https') and parsed.netloc:
return absolute
return None
@app.route('/api/poster', methods=['GET'])
def page_poster():
"""Ask a video's own page what picture it shows.
A listing's thumbnail can be dead on arrival. sxyprn hands out a still it
derives from the video (`.../vid/<token>/.../full.jpg`) which, for a post
made minutes ago, the CDN has not generated -- while the post page itself
shows one that works (`.../img/<other token>/.../0.webp`). Nothing on the
client can guess the second from the first: each path carries its own
signature. So when a thumbnail has failed every way we know to ask for it,
the page it came from gets asked what it shows.
Cheap to be wrong about and expensive to repeat, so answers -- including
"nothing" -- are cached for a few minutes."""
page_url = request.args.get('url')
if not page_url:
return jsonify({"error": "No URL provided"}), 400
parsed = urllib.parse.urlparse(page_url)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return jsonify({"error": "Invalid target URL"}), 400
now = time.time()
with _poster_cache_lock:
for key in [k for k, v in _poster_cache.items() if v[0] <= now]:
_poster_cache.pop(key, None)
hit = _poster_cache.get(page_url)
if hit:
return jsonify({"thumb": hit[1]})
thumb = None
sess = _borrow_session()
try:
# Streamed, and only the head of it: a page URL that turns out to
# redirect to the video itself (the Hot Tub proxy does exactly that for
# some channels) must cost one buffer, not a whole download.
resp = sess.get(page_url, headers={'Referer': page_url}, timeout=15,
allow_redirects=True, stream=True)
try:
content_type = (resp.headers.get('Content-Type') or '').lower()
if resp.status_code < 400 and ('html' in content_type or not content_type):
head = b''
for chunk in resp.iter_content(32 * 1024):
head += chunk
if len(head) >= _POSTER_SCAN_BYTES:
break
thumb = _poster_from_html(
head.decode(resp.encoding or 'utf-8', errors='replace'),
resp.url or page_url)
finally:
resp.close()
except Exception:
_discard_session(sess)
sess = None
finally:
if sess is not None:
_return_session(sess)
with _poster_cache_lock:
_poster_cache[page_url] = (now + POSTER_CACHE_TTL, thumb)
return jsonify({"thumb": thumb})
@app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy():
image_url = request.args.get('url')