A favorite outlives its thumbnail. Sources sign their CDN paths and the signature expires, so a video kept months ago has a `thumb` that 404s and a card that is a grey box with a title under it. Asking the source page for a fresh picture covers the case where the page still exists; nothing covered the case where it doesn't. The video does, because playback re-resolves from the page URL every time. So once every route to the listed picture is exhausted, a frame a quarter of the way in becomes the card's picture -- past the black lead-in, and a fair guess at what the video looks like. It goes through our own proxy, so the canvas can be read back, is scaled to card width and JPEG'd to a handful of KB, and is kept: later mounts and later sessions paint it without a request, instead of asking again for a picture that is known to be gone. Only for favorites, once per item, one at a time, and never for a live stream or a manifest. Two things worth knowing, both found with the browser rather than reasoned about: a browser fires `seeked` for its own initial seek to zero, so taking the frame on the first one captures the opening of every video; and the same item is on screen in more than one place, so the captures are deduped by a promise rather than the first asker getting the picture and the rest being told there isn't one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
289 lines
12 KiB
Python
289 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""A favorite whose thumbnail has expired gets a frame out of the video instead.
|
|
|
|
Run against a locally running backend:
|
|
|
|
backend/main.py &
|
|
.venv/bin/python tests/smoke_poster_capture.py
|
|
|
|
Everything the client would reach for is a fixture, and every one of them is
|
|
dead on purpose: the listed picture 404s on both routes, the image proxy 404s,
|
|
and the source page has no replacement to offer. That is the state a favorite
|
|
saved months ago is actually in -- the signature on its CDN thumbnail expired --
|
|
and what the test asks is that the card still ends up with a picture of the
|
|
video, scaled down, kept, and not fetched again.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
# 1280x720, eight seconds. Wide on purpose: the capture has to scale down to
|
|
# card size, and a fixture no wider than the cache's own width would never show
|
|
# whether it does.
|
|
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "wide.mp4")
|
|
|
|
BASE = "http://127.0.0.1:5000/"
|
|
SERVER = "https://fixture.test"
|
|
CHANNEL = "clips"
|
|
# What App.posters scales a frame down to, and what it keeps the cache under.
|
|
POSTER_WIDTH = 360
|
|
CACHE_KEY = "posterCache"
|
|
|
|
STATUS = {
|
|
"online": True,
|
|
"channels": [{
|
|
"id": CHANNEL,
|
|
"name": "Fixture Clips",
|
|
"description": "A channel that only exists for this test.",
|
|
"options": [],
|
|
}],
|
|
}
|
|
|
|
# The favorite is the first item of the listing, saved with the thumbnail it
|
|
# had at the time -- the one that no longer loads.
|
|
SEED = """([server, channel]) => {
|
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
|
localStorage.removeItem('session');
|
|
localStorage.removeItem('posterCache');
|
|
Object.keys(localStorage).forEach((k) => {
|
|
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
|
|
});
|
|
localStorage.setItem('favorites', JSON.stringify([{
|
|
key: 'vid-0',
|
|
id: 'vid-0',
|
|
url: 'https://fixture.test/v0.mp4',
|
|
title: 'kept clip 0',
|
|
thumb: 'https://pictures.fixture.test/gone/0.jpg',
|
|
channel: channel,
|
|
uploader: '',
|
|
duration: 8,
|
|
isLive: false,
|
|
favoriteDate: new Date().toISOString()
|
|
}]));
|
|
}"""
|
|
|
|
# The picture on a card, and whether it is one: a broken <img> has no
|
|
# intrinsic size.
|
|
SHOT = """(id) => {
|
|
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
|
|
if (!card) return null;
|
|
const img = card.querySelector('.video-thumb img');
|
|
return {
|
|
src: (img.getAttribute('src') || '').slice(0, 24),
|
|
length: (img.getAttribute('src') || '').length,
|
|
width: img.naturalWidth,
|
|
height: img.naturalHeight
|
|
};
|
|
}"""
|
|
|
|
CACHE = """() => {
|
|
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
|
|
return index.map((entry) => ({
|
|
bytes: entry.n,
|
|
stored: (localStorage.getItem(`posterCache:${entry.k}`) || '').slice(0, 24),
|
|
length: (localStorage.getItem(`posterCache:${entry.k}`) || '').length
|
|
}));
|
|
}"""
|
|
|
|
PIXEL = """([id, x, y]) => new Promise((resolve) => {
|
|
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
|
|
const img = card ? card.querySelector('.video-thumb img') : null;
|
|
if (!img) { resolve(null); return; }
|
|
const probe = new Image();
|
|
probe.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = probe.width;
|
|
canvas.height = probe.height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(probe, 0, 0);
|
|
const px = ctx.getImageData(x, y, 1, 1).data;
|
|
resolve({ r: px[0], g: px[1], b: px[2] });
|
|
};
|
|
probe.onerror = () => resolve(null);
|
|
probe.src = img.getAttribute('src');
|
|
})"""
|
|
|
|
CAPTURED = """(id) => {
|
|
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
|
|
if (!card) return false;
|
|
const img = card.querySelector('.video-thumb img');
|
|
return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0;
|
|
}"""
|
|
|
|
|
|
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 items():
|
|
# The Referer is what gives an item a source page, which is the repair that
|
|
# comes before a capture. A favorite has none -- normalize() keeps no
|
|
# headers, since theirs expire -- so a saved item goes straight to the
|
|
# frame, while the same item in the listing walks the whole ladder.
|
|
return [{"id": f"vid-{i}", "title": f"clip {i}",
|
|
"url": f"https://fixture.test/v{i}.mp4",
|
|
"thumb": f"https://pictures.fixture.test/gone/{i}.jpg",
|
|
"http_headers": {"Referer": "https://page.fixture.test/"},
|
|
"channel": CHANNEL, "duration": 8, "isLive": False, "tags": []}
|
|
for i in range(3)]
|
|
|
|
|
|
def main():
|
|
c = Checks()
|
|
streamed = []
|
|
posters = []
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(args=[
|
|
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
|
"--autoplay-policy=no-user-gesture-required",
|
|
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
|
|
])
|
|
page = browser.new_page(viewport={"width": 1180, "height": 920})
|
|
crashes = []
|
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
|
|
|
page.route("**/api/status", lambda route: route.fulfill(
|
|
status=200, content_type="application/json", body=json.dumps(STATUS)))
|
|
page.route("**/api/videos", lambda route: route.fulfill(
|
|
status=200, content_type="application/json",
|
|
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
|
|
|
|
# Every way to the listed picture is gone: the provider, and our own
|
|
# image proxy standing in front of it.
|
|
page.route(re.compile(r"pictures\.fixture\.test"),
|
|
lambda route: route.fulfill(status=404, body=""))
|
|
page.route(re.compile(r"/api/image"),
|
|
lambda route: route.fulfill(status=404, body=""))
|
|
|
|
# And the source page has no fresher one to read off.
|
|
def poster(route):
|
|
posters.append(route.request.url)
|
|
route.fulfill(status=200, content_type="application/json",
|
|
body=json.dumps({"thumb": ""}))
|
|
page.route(re.compile(r"/api/poster"), poster)
|
|
|
|
# The video itself, which is the one thing that still works. Served
|
|
# the way the real proxy serves it -- byte ranges and all -- because a
|
|
# response a browser cannot seek in is one it will not capture a frame
|
|
# a quarter of the way into.
|
|
def stream(route):
|
|
streamed.append(route.request.url)
|
|
body = open(CLIP, "rb").read()
|
|
asked = route.request.headers.get("range") or ""
|
|
span = re.match(r"bytes=(\d+)-(\d*)", asked)
|
|
if span:
|
|
start = int(span.group(1))
|
|
end = int(span.group(2)) if span.group(2) else len(body) - 1
|
|
chunk = body[start:end + 1]
|
|
route.fulfill(status=206, body=chunk, headers={
|
|
"Content-Type": "video/mp4",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Range": f"bytes {start}-{end}/{len(body)}",
|
|
"Content-Length": str(len(chunk)),
|
|
})
|
|
return
|
|
route.fulfill(status=200, body=body, headers={
|
|
"Content-Type": "video/mp4",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(len(body)),
|
|
})
|
|
page.route(re.compile(r"/api/stream"), stream)
|
|
|
|
page.goto(BASE, wait_until="domcontentloaded")
|
|
page.evaluate(SEED, [SERVER, CHANNEL])
|
|
page.goto(BASE, wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=30000)
|
|
|
|
print("\nthe listed picture is gone, on every route there is")
|
|
c.ok("the module is there", page.evaluate("() => typeof App.posters.capture") == 'function')
|
|
c.ok("nothing is cached to begin with", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
|
|
|
|
print("\nso the card takes a frame out of the video")
|
|
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
|
|
shot = page.evaluate(SHOT, "vid-0")
|
|
c.ok("the favorite's thumbnail is a captured frame",
|
|
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
|
|
c.ok("and it decoded, so it is a picture and not an empty box",
|
|
shot["height"] > 0, str(shot))
|
|
c.ok("scaled down to card width, not kept at video size",
|
|
shot["width"] == POSTER_WIDTH, str(shot["width"]))
|
|
c.ok("the aspect ratio came through",
|
|
abs(shot["height"] - round(POSTER_WIDTH * 720 / 1280)) <= 1, str(shot))
|
|
c.ok("it was asked for at the quarter mark, from the video itself",
|
|
any("/api/stream" in url for url in streamed), str(streamed[:2]))
|
|
c.ok("and the source page was asked for a fresh picture before that",
|
|
any("/api/poster" in url for url in posters), str(posters[:2]))
|
|
|
|
print("\nthe frame is the one a quarter of the way in")
|
|
# The fixture is three bands of flat colour, so the frame says when it
|
|
# was taken: red for the first second and a half, orange from there to
|
|
# two and a half -- which is where a quarter of eight seconds falls --
|
|
# and navy for the rest. A capture that drew the first frame it decoded
|
|
# instead of seeking would come out red, and one that waited for the
|
|
# middle, navy.
|
|
frame = page.evaluate(PIXEL, ["vid-0", 180, 100])
|
|
c.ok("the colour is the one the clip shows two seconds in",
|
|
frame is not None and frame["r"] > 190 and 100 < frame["g"] < 180 and frame["b"] < 110,
|
|
str(frame))
|
|
c.ok("so it is neither the opening frame nor the middle",
|
|
frame is not None and frame["g"] > 80 and not (frame["b"] > frame["r"]), str(frame))
|
|
|
|
print("\nand keeps it")
|
|
cached = page.evaluate(CACHE)
|
|
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
|
|
c.ok("stored as a JPEG data URL",
|
|
cached[0]["stored"].startswith("data:image/jpeg"), str(cached[0]["stored"]))
|
|
c.ok("small enough to keep a hundred of them",
|
|
0 < cached[0]["length"] < 60000, str(cached[0]["length"]))
|
|
c.ok("and the index says how big it is",
|
|
cached[0]["bytes"] == cached[0]["length"], str(cached[0]))
|
|
|
|
print("\nthe videos nobody kept are left alone")
|
|
others = [page.evaluate(SHOT, "vid-1"), page.evaluate(SHOT, "vid-2")]
|
|
c.ok("a card that is not a favorite captures nothing",
|
|
all(not (shot and shot["src"].startswith("data:")) for shot in others), str(others))
|
|
c.ok("so exactly one video was fetched for a picture",
|
|
len(streamed) == 1, str(streamed))
|
|
|
|
print("\nand the next session does not fetch it again")
|
|
streamed.clear()
|
|
posters.clear()
|
|
page.goto(BASE, wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=30000)
|
|
page.wait_for_timeout(1500)
|
|
shot = page.evaluate(SHOT, "vid-0")
|
|
c.ok("the kept picture is on the card straight away",
|
|
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
|
|
c.ok("without going back to the video", streamed == [], str(streamed))
|
|
c.ok("or to the source page", posters == [], str(posters))
|
|
c.ok("and it is still the same one picture in the cache",
|
|
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
|
|
|
|
print("\nthe favorites bar shows it too")
|
|
bar = page.evaluate("""() => {
|
|
const img = document.querySelector('.favorite-card img');
|
|
return img ? (img.getAttribute('src') || '').slice(0, 24) : null;
|
|
}""")
|
|
c.ok("the bar card is the captured frame as well",
|
|
bar is not None and bar.startswith("data:image/jpeg"), str(bar))
|
|
|
|
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())
|