Compare commits
2 Commits
a795442634
...
4b03789ef2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b03789ef2 | ||
|
|
051dae98cb |
@@ -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')
|
||||
|
||||
@@ -1393,18 +1393,40 @@ body.theme-light .favorite-btn {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
/* Clear of the copy/close buttons parked in the corner. */
|
||||
padding-right: 140px;
|
||||
}
|
||||
|
||||
.info-close {
|
||||
/* Copy and close sit together in the card's top corner, above the title. */
|
||||
.info-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-copy {
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-copy.is-copied {
|
||||
border-color: var(--border-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-close {
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,10 @@
|
||||
|
||||
<div id="info-modal" class="info-modal" aria-hidden="true">
|
||||
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||
<div class="info-actions">
|
||||
<button id="info-copy" class="btn-secondary info-copy" type="button">Copy JSON</button>
|
||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<h3 id="info-title">Video Info</h3>
|
||||
<div id="info-list" class="info-list"></div>
|
||||
<div id="info-empty" class="info-empty">No additional info available.</div>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,62 @@ App.ui = App.ui || {};
|
||||
// Which video the panel is currently showing, so a slow resolve that lands
|
||||
// after the user moved on doesn't redraw someone else's panel.
|
||||
let infoVideo = null;
|
||||
// Exactly what the panel is showing, as one object -- what the copy button
|
||||
// hands over. Kept beside the rendering rather than rebuilt on click, so
|
||||
// the two can't disagree about what "this video" means.
|
||||
let infoPayload = null;
|
||||
|
||||
// The clipboard proper needs a secure context, which a home-screen app on
|
||||
// https has and a plain-http LAN address does not -- hence the old
|
||||
// execCommand path behind it, which only works on a selection in the
|
||||
// document.
|
||||
const copyText = async function(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) { /* fall through to the old way */ }
|
||||
}
|
||||
const scratch = document.createElement('textarea');
|
||||
scratch.value = text;
|
||||
scratch.setAttribute('readonly', '');
|
||||
// Off-screen but focusable: display:none or visibility:hidden would
|
||||
// leave nothing to select, and a visible one would scroll the page.
|
||||
scratch.style.position = 'fixed';
|
||||
scratch.style.top = '-1000px';
|
||||
scratch.style.opacity = '0';
|
||||
document.body.appendChild(scratch);
|
||||
try {
|
||||
scratch.select();
|
||||
return document.execCommand('copy');
|
||||
} catch (err) {
|
||||
return false;
|
||||
} finally {
|
||||
scratch.remove();
|
||||
}
|
||||
};
|
||||
|
||||
let copyResetTimer = null;
|
||||
|
||||
App.ui.copyInfoJson = async function() {
|
||||
const button = document.getElementById('info-copy');
|
||||
if (!infoPayload) return false;
|
||||
const copied = await copyText(JSON.stringify(infoPayload, null, 2));
|
||||
if (!copied) {
|
||||
App.ui.showError('Could not copy to the clipboard.');
|
||||
return false;
|
||||
}
|
||||
if (button) {
|
||||
button.classList.add('is-copied');
|
||||
button.textContent = 'Copied';
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyResetTimer = setTimeout(() => {
|
||||
button.classList.remove('is-copied');
|
||||
button.textContent = 'Copy JSON';
|
||||
}, 1600);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const appendInfoHeading = function(list, label) {
|
||||
const heading = document.createElement('div');
|
||||
@@ -131,12 +187,16 @@ App.ui = App.ui || {};
|
||||
|
||||
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
|
||||
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
const section = opts.info ? 'extractor' : 'resolved';
|
||||
infoPayload = Object.assign({}, own);
|
||||
if (resolved && typeof resolved === 'object') infoPayload[section] = resolved;
|
||||
|
||||
let rows = 0;
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
rows += appendInfoRows(list, own);
|
||||
|
||||
if (resolved && typeof resolved === 'object') {
|
||||
@@ -166,6 +226,13 @@ App.ui = App.ui || {};
|
||||
// nothing (or at a spinner) while it does.
|
||||
App.ui.openInfo = function(video) {
|
||||
infoVideo = video;
|
||||
// A fresh panel, so the button stops saying it copied the last one.
|
||||
const copyBtn = document.getElementById('info-copy');
|
||||
if (copyBtn) {
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyBtn.classList.remove('is-copied');
|
||||
copyBtn.textContent = 'Copy JSON';
|
||||
}
|
||||
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
|
||||
App.ui.showInfo(video, { pending: canResolve });
|
||||
if (!canResolve) return;
|
||||
@@ -179,6 +246,7 @@ App.ui = App.ui || {};
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
infoVideo = null;
|
||||
infoPayload = null;
|
||||
modal.classList.remove('open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
@@ -1098,5 +1166,12 @@ App.ui = App.ui || {};
|
||||
App.ui.closeInfo();
|
||||
});
|
||||
}
|
||||
|
||||
const infoCopy = document.getElementById('info-copy');
|
||||
if (infoCopy) {
|
||||
infoCopy.addEventListener('click', () => {
|
||||
App.ui.copyInfoJson();
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -73,7 +73,14 @@ def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
# Lean flags, matching smoke_grid: this runs alongside the app's own
|
||||
# server and a default Chromium spikes hard enough at startup to get
|
||||
# itself killed on a constrained box.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
crashes = []
|
||||
|
||||
151
tests/smoke_info.py
Normal file
151
tests/smoke_info.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Info panel smoke tests: what the panel shows, and what Copy JSON hands over.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_info.py
|
||||
|
||||
The listing is served by this script so the expected JSON is known exactly --
|
||||
including the awkward parts of a real item (nested http_headers, a tag array,
|
||||
a zero, an empty string).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
ITEM = {
|
||||
"id": "test:copy-me",
|
||||
"title": "Summer Col and Damion Dayski",
|
||||
"url": "https://example.test/proxy/test/post/copy-me.html",
|
||||
"channel": "test",
|
||||
"duration": 0,
|
||||
"isLive": False,
|
||||
"views": 0,
|
||||
"uploader": "",
|
||||
"thumb": "https://cdn.example.test/thumb.png",
|
||||
"tags": ["sex", "bigtits", "bigass", "hardcore"],
|
||||
"preview": "https://example.test/proxy/test/post/copy-me.html",
|
||||
"http_headers": {"Referer": "https://example.test/"},
|
||||
}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
# Lean flags, matching smoke_grid: this runs alongside the app's own
|
||||
# server and a default Chromium spikes hard enough at startup to get
|
||||
# itself killed on a constrained box.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||
# The real clipboard, so the button's own path is what runs.
|
||||
context.grant_permissions(["clipboard-read", "clipboard-write"])
|
||||
page = context.new_page()
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
page.route("**/api/videos", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"items": [ITEM], "pageInfo": {"hasNextPage": False}})))
|
||||
page.route("**/api/resolve*", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"formats": [], "http_headers": {}, "isLive": False, "url": None})))
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
print("\nthe panel opens with a copy button")
|
||||
page.evaluate("""() => {
|
||||
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
|
||||
App.ui.openInfo(v);
|
||||
}""")
|
||||
page.wait_for_timeout(500)
|
||||
c.ok("the panel is open",
|
||||
page.evaluate("() => document.getElementById('info-modal').classList.contains('open')"))
|
||||
c.ok("the button is visible", page.is_visible("#info-copy"))
|
||||
c.ok("and reads Copy JSON",
|
||||
page.inner_text("#info-copy").strip() == "Copy JSON",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nclicking it puts the item on the clipboard")
|
||||
page.click("#info-copy")
|
||||
page.wait_for_timeout(600)
|
||||
clipboard = page.evaluate("() => navigator.clipboard.readText()")
|
||||
try:
|
||||
copied = json.loads(clipboard)
|
||||
except ValueError:
|
||||
copied = None
|
||||
c.ok("what landed there is JSON", copied is not None, clipboard[:120])
|
||||
if copied:
|
||||
missing = [k for k, v in ITEM.items() if k not in copied]
|
||||
c.ok("with every field the item has", not missing, str(missing))
|
||||
c.ok("values intact, nested ones included",
|
||||
copied.get("http_headers") == ITEM["http_headers"]
|
||||
and copied.get("tags") == ITEM["tags"], str(copied.get("http_headers")))
|
||||
c.ok("a zero is a zero, not a dash",
|
||||
copied.get("duration") == 0 and copied.get("views") == 0, str(copied.get("duration")))
|
||||
c.ok("and an empty string stays empty",
|
||||
copied.get("uploader") == "", repr(copied.get("uploader")))
|
||||
c.ok("it is pretty-printed, not one line", "\n" in clipboard)
|
||||
c.ok("the button says so", page.inner_text("#info-copy").strip() == "Copied",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nand it goes back to normal")
|
||||
page.wait_for_timeout(2000)
|
||||
c.ok("the label resets", page.inner_text("#info-copy").strip() == "Copy JSON",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nthe resolved payload rides along once it lands")
|
||||
page.evaluate("""() => {
|
||||
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
|
||||
App.ui.showInfo(v, { info: { extractorField: 'yes', formats: [] } });
|
||||
}""")
|
||||
page.wait_for_timeout(300)
|
||||
page.click("#info-copy")
|
||||
page.wait_for_timeout(600)
|
||||
second = json.loads(page.evaluate("() => navigator.clipboard.readText()"))
|
||||
c.ok("the extractor's fields are in there too",
|
||||
(second.get("extractor") or {}).get("extractorField") == "yes",
|
||||
str(list(second.keys())))
|
||||
c.ok("and the item's own are still there", second.get("id") == ITEM["id"])
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
@@ -17,16 +17,32 @@ provider, because what's under test is what the client does with awkward data:
|
||||
|
||||
* a thumbnail whose first request fails. One blip used to mean an empty box
|
||||
for as long as the card stayed mounted.
|
||||
|
||||
* a thumbnail that is simply gone. sxyprn's CDN paths are signed with an
|
||||
expiry and the listing hands out URLs that have passed theirs, so the
|
||||
newest posts arrive pointing at a 404. The item's own page still shows a
|
||||
working picture, and /api/poster reads it off.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
CDN = "https://cdn.example-thumbs.test"
|
||||
# Where a "provider page" lives, and the picture it says it has -- the shape
|
||||
# /api/poster reads. The proxy path mirrors the Hot Tub server's own, so the
|
||||
# client has to rebuild the site address from the item's Referer to find it.
|
||||
SITE = "https://site.example-thumbs.test"
|
||||
PROXY_PAGE = "https://proxy.example-thumbs.test/proxy/test/post/gone.html"
|
||||
|
||||
# 1x1 transparent PNG.
|
||||
PIXEL = base64.b64decode(
|
||||
@@ -49,6 +65,66 @@ STATE = """() => Array.from(document.querySelectorAll('#video-grid .video-card')
|
||||
})"""
|
||||
|
||||
|
||||
PAGE_HTML = """<!doctype html><html><head>
|
||||
<meta property='og:title' content='A video'/>
|
||||
<meta property='og:image' content='//pictures.example.test/fresh/poster.webp'/>
|
||||
<meta itemprop="thumbnailUrl" content="//pictures.example.test/other.webp" />
|
||||
</head><body><video poster='//pictures.example.test/player.webp'></video></body></html>"""
|
||||
|
||||
|
||||
class PageServer(threading.Thread):
|
||||
"""A page for /api/poster to read, and one that redirects to a video --
|
||||
which is what the Hot Tub proxy does, and what must not be downloaded."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
outer = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/post"):
|
||||
body = PAGE_HTML.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path.startswith("/video"):
|
||||
body = b"\0" * (4 * 1024 * 1024)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
outer.port = self.httpd.server_port
|
||||
|
||||
def run(self):
|
||||
self.httpd.serve_forever()
|
||||
|
||||
def stop(self):
|
||||
self.httpd.shutdown()
|
||||
|
||||
|
||||
GONE_STATE = """() => {
|
||||
const card = document.querySelector('#video-grid .video-card[data-video-id="test:9"]');
|
||||
const img = card && card.querySelector('img');
|
||||
return { src: img ? img.getAttribute('src') || '' : '', loaded: !!(img && img.naturalWidth > 0) };
|
||||
}"""
|
||||
|
||||
|
||||
def gone_state(page):
|
||||
return page.evaluate(GONE_STATE)
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
@@ -75,17 +151,51 @@ def listing():
|
||||
})
|
||||
items[3]["thumb"] = "https:" # what sxyprn's "latest" actually sends
|
||||
items[7]["thumb"] = f"{CDN}/flaky.png"
|
||||
# The expired-signature case: a dead thumbnail, and an item that says
|
||||
# enough about where it came from for the page to be found.
|
||||
items[9]["thumb"] = f"{CDN}/expired/gone.jpg"
|
||||
items[9]["url"] = PROXY_PAGE
|
||||
items[9]["channel"] = "test"
|
||||
items[9]["http_headers"] = {"Referer": f"{SITE}/"}
|
||||
return {"items": items, "pageInfo": {"hasNextPage": False}}
|
||||
|
||||
|
||||
def poster_of(page_url):
|
||||
"""What /api/poster answers for a page, straight from the backend.
|
||||
|
||||
Returns None if the backend doesn't have the endpoint -- which on a machine
|
||||
that has been running since before it existed means "restart it", not
|
||||
"broken"."""
|
||||
url = BASE.rstrip("/") + "/api/poster?url=" + urllib.parse.quote(page_url, safe="")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
image_proxy_calls = []
|
||||
poster_asks = []
|
||||
flaky_hits = {"direct": 0, "proxy": 0}
|
||||
|
||||
pages = PageServer()
|
||||
pages.start()
|
||||
local = f"http://127.0.0.1:{pages.port}"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
# Lean flags, matching smoke_grid: this runs alongside the app's own
|
||||
# server and a default Chromium spikes hard enough at startup to get
|
||||
# itself killed on a constrained box.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
|
||||
@@ -97,6 +207,9 @@ def main():
|
||||
# Only the thumbnails matter here; record what the client asked us
|
||||
# to fetch on its behalf, and hand back the picture.
|
||||
image_proxy_calls.append(route.request.url)
|
||||
if "%2Fexpired%2F" in route.request.url or "/expired/" in route.request.url:
|
||||
route.fulfill(status=404, content_type="text/html", body="gone")
|
||||
return
|
||||
if "flaky.png" in route.request.url:
|
||||
flaky_hits["proxy"] += 1
|
||||
# The flaky picture is refused on *both* routes the first time
|
||||
@@ -106,7 +219,19 @@ def main():
|
||||
return
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
def serve_poster(route):
|
||||
# The endpoint itself is exercised against a real page below; here
|
||||
# only the client's half is under test, so the answer is canned.
|
||||
asked = urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(route.request.url).query).get("url", [""])[0]
|
||||
poster_asks.append(asked)
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=json.dumps({"thumb": f"{CDN}/repaired.png"}))
|
||||
|
||||
def serve_cdn(route):
|
||||
if "/expired/" in route.request.url:
|
||||
route.fulfill(status=404, content_type="text/html", body="gone")
|
||||
return
|
||||
if route.request.url.endswith("/flaky.png"):
|
||||
flaky_hits["direct"] += 1
|
||||
if flaky_hits["direct"] == 1:
|
||||
@@ -115,6 +240,7 @@ def main():
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
page.route("**/api/videos", serve_listing)
|
||||
page.route("**/api/poster*", serve_poster)
|
||||
page.route("**/api/image*", serve_proxy)
|
||||
page.route(f"{CDN}/**", serve_cdn)
|
||||
|
||||
@@ -140,7 +266,8 @@ def main():
|
||||
str([u for u in image_proxy_calls if "https%3A" in u][:2]))
|
||||
|
||||
print("\nthe rest of the page is unaffected by it")
|
||||
others = [card for card in cards if card["id"] != "test:3"]
|
||||
# test:9's thumbnail is deliberately dead; it has a section of its own.
|
||||
others = [card for card in cards if card["id"] not in ("test:3", "test:9")]
|
||||
c.ok("every other card shows its picture",
|
||||
all(card["loaded"] for card in others),
|
||||
str([card["id"] for card in others if not card["loaded"]]))
|
||||
@@ -166,7 +293,38 @@ def main():
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] <= 4,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
|
||||
print("\na thumbnail that is simply gone")
|
||||
gone = by_id.get("test:9")
|
||||
c.ok("the card is mounted", gone is not None)
|
||||
c.ok("its page was asked what picture it shows", poster_asks,
|
||||
str(poster_asks))
|
||||
# The item points at a proxy path; the page lives on the site named by
|
||||
# the Referer the item carries.
|
||||
c.ok("and the page asked for was the item's own, on its own site",
|
||||
poster_asks and poster_asks[0] == f"{SITE}/post/gone.html",
|
||||
str(poster_asks[:2]))
|
||||
c.ok("the card ends up showing the replacement",
|
||||
gone and gone_state(page)["loaded"], str(gone_state(page)))
|
||||
c.ok("one ask is enough for that page", len(poster_asks) == 1, str(poster_asks))
|
||||
|
||||
print("\nwhat /api/poster reads off a page")
|
||||
answer = poster_of(f"{local}/post.html")
|
||||
if answer is None:
|
||||
c.ok("the backend has /api/poster (restart it if this fails)", False)
|
||||
answer = {}
|
||||
else:
|
||||
c.ok("the backend has /api/poster", True)
|
||||
c.ok("the page's own og:image, made absolute",
|
||||
answer.get("thumb") == f"http://pictures.example.test/fresh/poster.webp",
|
||||
str(answer))
|
||||
video = poster_of(f"{local}/video.mp4")
|
||||
c.ok("a URL that turns out to be a video yields nothing",
|
||||
video.get("thumb") is None, str(video))
|
||||
missing = poster_of(f"{local}/nope.html")
|
||||
c.ok("and so does a page that isn't there", missing.get("thumb") is None, str(missing))
|
||||
|
||||
browser.close()
|
||||
pages.stop()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
Reference in New Issue
Block a user