Compare commits
37 Commits
thumbnail-
...
c60f5e01dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60f5e01dc | ||
|
|
7033288bc4 | ||
|
|
872786a657 | ||
|
|
7fc718158a | ||
|
|
4f772773e1 | ||
|
|
be058dba37 | ||
|
|
6dac1028a3 | ||
|
|
e8eac70f4d | ||
|
|
d5c859a1c7 | ||
|
|
6d9cc301a7 | ||
|
|
b9f6a78fdc | ||
|
|
bc7c0047b7 | ||
|
|
f977852253 | ||
|
|
553d610e5b | ||
|
|
adb254c20b | ||
|
|
8163693150 | ||
|
|
1b38f9d9e7 | ||
|
|
741786c7cf | ||
|
|
39a47289b9 | ||
|
|
b8a51d9ee2 | ||
|
|
b4bc90372d | ||
|
|
4b03789ef2 | ||
|
|
051dae98cb | ||
|
|
a795442634 | ||
|
|
b478c51551 | ||
|
|
191c81e55e | ||
|
|
2abdc31f56 | ||
|
|
76a078b9b5 | ||
|
|
451bf0f983 | ||
|
|
764e3416a3 | ||
|
|
f0df53365d | ||
|
|
6e5ab68a94 | ||
|
|
e603111d70 | ||
|
|
7624ca559a | ||
|
|
49992c1db0 | ||
|
|
b4031b5d0e | ||
|
|
1dbac33359 |
@@ -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')
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* A card keeps its optional parts (live badge, uploader, duration, tags) at all
|
||||
times and hides the ones this video doesn't need, so any pooled card fits any
|
||||
video -- see bindCard. Several of those carry their own `display`, which beats
|
||||
the UA rule for [hidden], so say it once and mean it. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
:root {
|
||||
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
|
||||
--bg-primary: #14110d;
|
||||
@@ -133,7 +139,8 @@ body.theme-light .top-bar {
|
||||
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 10px 44px 10px 16px;
|
||||
/* Room on the right for the clear button and the bookmark beside it. */
|
||||
padding: 10px 78px 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
@@ -156,7 +163,7 @@ body.theme-light .top-bar {
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
right: 44px;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -185,6 +192,53 @@ body.theme-light .top-bar {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Recent searches, under the box. The drawers' colours, but laid over the page
|
||||
background rather than letting it through: this one hangs over thumbnails,
|
||||
and a title showing through a suggestion is a title you try to read. */
|
||||
.search-suggest {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(var(--bg-secondary), var(--bg-secondary)), var(--bg-primary);
|
||||
box-shadow: 0 18px 40px var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-suggest-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-suggest-row:hover,
|
||||
.search-suggest-row.is-active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The part that matched what is typed, so the eye can see why a row is here. */
|
||||
.search-suggest-hit {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -1041,6 +1095,12 @@ body.favorites-view-open .favorites-empty {
|
||||
precomputed (top,left). */
|
||||
}
|
||||
|
||||
/* Each card's layout and paint stay its own business, so inserting one during
|
||||
a scroll cannot make the browser reconsider the rest of the grid. */
|
||||
.video-card {
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 16px 28px var(--shadow);
|
||||
@@ -1312,7 +1372,7 @@ body.theme-light .video-menu-btn {
|
||||
}
|
||||
|
||||
/* Live streams can't be seeked, so hide the feed scrubber. */
|
||||
.feed-slide.is-live .feed-timeline {
|
||||
.feed-pane.is-live .feed-timeline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1381,18 +1441,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;
|
||||
}
|
||||
|
||||
@@ -1494,6 +1576,22 @@ body.theme-light .favorite-btn {
|
||||
animation: cp-open 0.22s ease;
|
||||
}
|
||||
|
||||
/* Loading before it is shown: laid out, so the media element loads as it
|
||||
normally would (a display:none video doesn't, on iOS), but invisible and
|
||||
transparent to the pointer, so the grid underneath stays the page the
|
||||
viewer is on. */
|
||||
.custom-player.is-preloading,
|
||||
/* The HUD's own children opt back into pointer events, so they have to be
|
||||
told as well -- an invisible close button must not eat a card's click. */
|
||||
.custom-player.is-preloading * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.custom-player.is-preloading {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cp-open {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -2138,6 +2236,27 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* A turn goes through every shape between the one it left and the one it
|
||||
lands in, and each of those re-letterboxes the picture somewhere else --
|
||||
a run of jumps where there should be one. So for the length of the turn
|
||||
the slides are held at the size it is heading for (feed.js sets the two
|
||||
numbers): the picture moves once, straight into its final position and
|
||||
size, and stays there while the viewport catches up to it. */
|
||||
.feed-view.is-turning .feed-slide {
|
||||
width: var(--turn-w);
|
||||
height: var(--turn-h);
|
||||
}
|
||||
|
||||
.feed-view.is-turning .feed-scroll {
|
||||
/* The pinned slides are a different shape from the viewport until the turn
|
||||
finishes, and none of that overflow is for scrolling through. Snapping
|
||||
is off with it: the container would otherwise be re-anchoring on the old
|
||||
height at the same time as the feed is pinning itself to the new one. */
|
||||
overflow: hidden;
|
||||
scroll-snap-type: none;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Stands in for the slides above the rendered window so scroll position and
|
||||
snap points stay stable while slides are virtualized in and out. */
|
||||
.feed-top-spacer {
|
||||
@@ -2146,13 +2265,49 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* One screenful. It holds the pane tree rather than a video directly: with
|
||||
several panes, a step shows that many videos at once and one swipe advances
|
||||
the whole set.
|
||||
|
||||
The height is the scroller's own, not 100dvh: every scroll position in the
|
||||
feed is computed as `index * scroller.clientHeight` (the top spacer stands
|
||||
in for the slides above), so a slide that is even a few pixels off that
|
||||
height makes the mapping drift by a slide's worth after a handful of them --
|
||||
which is what used to make rotating the phone land on a different video. */
|
||||
.feed-slide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: 100%;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* A branch of the tree: two children sharing the space, side by side or
|
||||
stacked. Nesting these is what makes any arrangement reachable. */
|
||||
.feed-split {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.feed-split.is-row { flex-direction: row; }
|
||||
.feed-split.is-col { flex-direction: column; }
|
||||
|
||||
/* min-* is what stops a flex child refusing to shrink below its content. */
|
||||
.feed-split > .feed-pane,
|
||||
.feed-split > .feed-split {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feed-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2160,6 +2315,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.feed-pane-tools {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
/* Above the video, below the right-hand controls it sits beside. */
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.feed-pane-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(244, 232, 212, 0.18);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-pane-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.feed-pane-mute.is-muted {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The HUD's idle fade takes the panel tools with it, like every other control. */
|
||||
.feed-hud-idle .feed-pane-tools {
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.feed-poster,
|
||||
.feed-video {
|
||||
position: absolute;
|
||||
@@ -2173,7 +2367,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-slide.is-loaded .feed-poster {
|
||||
.feed-pane.is-loaded .feed-poster {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2298,6 +2492,22 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
}
|
||||
|
||||
/* Per-slide favorite (heart) button on the feed HUD right rail. */
|
||||
/* The right-hand controls stack upwards from just above the caption, rather
|
||||
than sitting at fixed distances from the bottom. A pane can be a third of
|
||||
the viewport tall, and viewport-scale offsets put these outside it -- clipped
|
||||
by the pane's own overflow and unreachable. */
|
||||
.feed-pane .feed-fav-btn,
|
||||
.feed-pane .feed-pip-btn,
|
||||
.feed-pane .feed-format-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
top: 56px;
|
||||
}
|
||||
|
||||
.feed-pane .feed-pip-btn { top: 100px; }
|
||||
.feed-pane .feed-format-btn { top: 144px; }
|
||||
|
||||
.feed-fav-btn {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
@@ -2366,13 +2576,13 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
|
||||
interactive (pointer-events untouched) so the buttons keep working while
|
||||
invisible; any pointer/scroll activity reveals them again (see App.feed). */
|
||||
body.feed-hud-idle .feed-info,
|
||||
body.feed-hud-idle .feed-timeline,
|
||||
body.feed-hud-idle .feed-mute-btn,
|
||||
body.feed-hud-idle .feed-fav-btn,
|
||||
body.feed-hud-idle .feed-pip-btn,
|
||||
body.feed-hud-idle .feed-format-btn,
|
||||
body.feed-hud-idle .mode-toggle-btn {
|
||||
.feed-hud-idle .feed-info,
|
||||
.feed-hud-idle .feed-timeline,
|
||||
.feed-hud-idle .feed-mute-btn,
|
||||
.feed-hud-idle .feed-fav-btn,
|
||||
.feed-hud-idle .feed-pip-btn,
|
||||
.feed-hud-idle .feed-format-btn,
|
||||
.feed-hud-idle .mode-toggle-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2606,6 +2816,257 @@ body.theme-light .video-card img:not(.is-loaded) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Channel picker ---------------------------------------------------- */
|
||||
/* The trigger in the menu drawer: what's being read now, favicon and all. */
|
||||
.channel-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.channel-trigger:hover,
|
||||
.channel-trigger:focus-visible {
|
||||
border-color: var(--border-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.channel-trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-name,
|
||||
.channel-trigger-note {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-trigger-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-caret {
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A favicon, with the channel's initial behind it for the ones that never
|
||||
arrive -- a row is never a name beside an empty square. */
|
||||
.channel-mark {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(201, 165, 103, 0.14);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-mark::before {
|
||||
content: attr(data-letter);
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-favicon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 3px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.channel-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4200;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 12px 12px 12px;
|
||||
background: rgba(10, 8, 4, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.channel-picker.open { display: flex; }
|
||||
|
||||
.channel-picker-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: min(74vh, 700px);
|
||||
/* Solid, not the usual translucent panel: the section headings stick over
|
||||
the rows as they scroll, and a see-through heading is unreadable. */
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 30px 70px var(--shadow);
|
||||
overflow: hidden;
|
||||
animation: card-rise 0.2s ease both;
|
||||
}
|
||||
|
||||
.channel-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 10px 18px;
|
||||
}
|
||||
|
||||
.channel-picker-head h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-search {
|
||||
margin: 0 18px 12px 18px;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-search::placeholder { color: var(--text-secondary); }
|
||||
.channel-search:focus { outline: none; border-color: var(--border-hover); }
|
||||
|
||||
.channel-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.channel-section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 10px 8px 6px 8px;
|
||||
background: var(--bg-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-row.is-active { background: var(--bg-tertiary); }
|
||||
|
||||
.channel-row.is-current {
|
||||
border-color: var(--border-hover);
|
||||
background: rgba(201, 165, 103, 0.10);
|
||||
}
|
||||
|
||||
.channel-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-row-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-row-note {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-flag {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-picker-empty {
|
||||
padding: 8px 20px 20px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.channel-picker { padding: 0; }
|
||||
|
||||
.channel-picker-box {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
|
||||
.feed-title { font-family: var(--font-display); }
|
||||
|
||||
@@ -2627,3 +3088,400 @@ body.theme-light .video-card img:not(.is-loaded) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* Privacy veil -- the page goes behind a blur whenever this window isn't the
|
||||
one in front. Above everything else the app can put on screen, and painted
|
||||
as a backdrop filter rather than a filter on the page itself, which would
|
||||
make every fixed element inside it position against the wrong box. */
|
||||
.privacy-veil {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(10, 8, 6, 0.38);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(0.55);
|
||||
backdrop-filter: blur(26px) saturate(0.55);
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.privacy-veil.is-on {
|
||||
opacity: 1;
|
||||
/* Going behind the blur is immediate -- a fade would be a window during
|
||||
which the page is still readable. Coming back can take its time. */
|
||||
transition: none;
|
||||
/* So the tap that brings the window back lands here and lifts the veil,
|
||||
rather than pressing something on a page nobody could see. */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Nothing to blur through without backdrop-filter, so the veil covers instead. */
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.privacy-veil { background: var(--bg-primary); }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Saved searches (see savedSearches.js + App.ui.savedSearches)
|
||||
|
||||
One hairline per row and no boxes inside boxes: the list is read as a
|
||||
document, with the brass reserved for the one thing that is news -- how
|
||||
many videos a saved search has picked up since it was last opened.
|
||||
===================================================================== */
|
||||
|
||||
/* Line icons drawn in place rather than fetched: these sit inside buttons
|
||||
that change colour with state, and a tinted <img> cannot follow. */
|
||||
.icon-line {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* --- The bookmark in the search bar ---------------------------------- */
|
||||
.search-save-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.search-save-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.search-save-btn:active { transform: translateY(-50%) scale(0.94); }
|
||||
.search-save-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Saved: the glyph fills in, which is the whole state in one look. */
|
||||
.search-save-btn.is-saved { color: var(--accent); }
|
||||
.search-save-btn.is-saved .icon-line { fill: currentColor; stroke: currentColor; }
|
||||
|
||||
@keyframes saved-flash {
|
||||
0% { transform: translateY(-50%) scale(1); }
|
||||
35% { transform: translateY(-50%) scale(1.22); }
|
||||
100% { transform: translateY(-50%) scale(1); }
|
||||
}
|
||||
|
||||
.search-save-btn.is-just-saved { animation: saved-flash 0.42s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
|
||||
/* --- The header button and its count --------------------------------- */
|
||||
.saved-toggle { position: relative; }
|
||||
|
||||
.saved-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 5px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 0 2px rgba(20, 17, 13, 0.9);
|
||||
}
|
||||
|
||||
body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); }
|
||||
|
||||
/* --- The list ---------------------------------------------------------- */
|
||||
.saved-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4200;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 12px 12px 12px;
|
||||
background: rgba(10, 8, 4, 0.55);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.saved-panel.open { display: flex; }
|
||||
|
||||
.saved-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(620px, 100%);
|
||||
max-height: min(78vh, 760px);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 30px 70px var(--shadow);
|
||||
overflow: hidden;
|
||||
animation: card-rise 0.2s ease both;
|
||||
}
|
||||
|
||||
.saved-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 14px 20px;
|
||||
}
|
||||
|
||||
.saved-head h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.saved-head-note {
|
||||
margin-top: 4px;
|
||||
max-width: 44ch;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saved-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Two underlined links side by side need the space to read as two. */
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.saved-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@keyframes saved-row-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.saved-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Rows animate in when they arrive, and only then: a row already on screen
|
||||
that a background check wrote a new count into has not arrived anywhere. */
|
||||
.saved-row.is-new {
|
||||
animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: calc(var(--i, 0) * 40ms);
|
||||
}
|
||||
|
||||
.saved-list .saved-row:last-child { border-bottom: none; }
|
||||
|
||||
.saved-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.saved-row-main:hover { background: var(--bg-tertiary); }
|
||||
.saved-row-main:disabled { cursor: default; }
|
||||
|
||||
/* The count. Brass and filled when there is something new; a hairline ring
|
||||
and a single dot when there isn't, so an up-to-date row reads as quiet
|
||||
rather than as a zero. */
|
||||
.saved-count {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-hover);
|
||||
background: rgba(201, 165, 103, 0.16);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.saved-count.is-quiet {
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saved-row.is-unreachable .saved-count { border-style: dashed; }
|
||||
|
||||
.saved-row-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.saved-row-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-row-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-row-stamp {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-row-tools {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.saved-row:hover .saved-row-tools,
|
||||
.saved-row:focus-within .saved-row-tools { opacity: 1; }
|
||||
|
||||
.saved-tool {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.saved-tool .icon-line { width: 18px; height: 18px; }
|
||||
.saved-tool:hover { color: var(--accent); background: var(--bg-tertiary); border-color: var(--border); }
|
||||
|
||||
.saved-rename {
|
||||
width: 100%;
|
||||
padding: 5px 9px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.saved-rename:focus { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); }
|
||||
|
||||
/* Removing asks first, in the row itself -- no dialog on top of a dialog. */
|
||||
.saved-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.saved-confirm-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saved-confirm-btn {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.saved-confirm-btn:hover { background: var(--bg-tertiary); border-color: var(--border-hover); }
|
||||
|
||||
.saved-confirm-btn.is-danger { color: #e2a2a2; }
|
||||
body.theme-light .saved-confirm-btn.is-danger { color: #9f2f2d; }
|
||||
.saved-confirm-btn.is-danger:hover { border-color: currentColor; }
|
||||
|
||||
.saved-row.is-confirming .saved-row-main { opacity: 0.5; }
|
||||
|
||||
.saved-empty {
|
||||
padding: 4px 20px 20px 20px;
|
||||
max-width: 52ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.saved-foot {
|
||||
padding: 14px 20px 18px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.saved-add { width: 100%; }
|
||||
.saved-add:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.saved-panel { padding: 0; }
|
||||
|
||||
.saved-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Nothing to hover with, so the row's tools are simply there. */
|
||||
.saved-row-tools { opacity: 1; }
|
||||
.saved-row-stamp { white-space: normal; }
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.saved-row-tools { opacity: 1; }
|
||||
.search-save-btn { width: 34px; height: 34px; }
|
||||
}
|
||||
|
||||
@@ -15,13 +15,30 @@
|
||||
<header class="top-bar">
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos...">
|
||||
<input type="text" id="search-input" placeholder="Search videos..."
|
||||
autocomplete="off" role="combobox" aria-expanded="false"
|
||||
aria-controls="search-suggest" aria-autocomplete="list">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
<button class="search-save-btn" id="search-save-btn" type="button" aria-pressed="false"
|
||||
aria-label="Save this search" title="Save this search">
|
||||
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6.5 4.75h11a.75.75 0 0 1 .75.75v13.3l-6.25-4.1-6.25 4.1V5.5a.75.75 0 0 1 .75-.75z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
||||
</button>
|
||||
<button class="icon-btn saved-toggle" id="saved-searches-btn" type="button"
|
||||
title="Saved searches" aria-label="Saved searches" aria-haspopup="dialog" aria-expanded="false">
|
||||
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3.75h8a1 1 0 0 1 1 1V17l-5-3.2L7 17V4.75a1 1 0 0 1 1-1z"/>
|
||||
<path d="M18.5 6.5h.5a1 1 0 0 1 1 1v12.75l-3-2" stroke-opacity="0.55"/>
|
||||
</svg>
|
||||
<span class="saved-badge" id="saved-searches-badge" aria-hidden="true" hidden></span>
|
||||
</button>
|
||||
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
||||
</button>
|
||||
@@ -58,8 +75,17 @@
|
||||
<select id="source-select"></select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="channel-select">Channel</label>
|
||||
<select id="channel-select"></select>
|
||||
<label for="channel-picker-btn">Channel</label>
|
||||
<button id="channel-picker-btn" class="channel-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<span class="channel-mark" id="channel-trigger-mark">
|
||||
<img class="channel-favicon" id="channel-trigger-icon" alt="" decoding="async">
|
||||
</span>
|
||||
<span class="channel-trigger-text">
|
||||
<span class="channel-trigger-name" id="channel-trigger-name">No channel</span>
|
||||
<span class="channel-trigger-note" id="channel-trigger-note"></span>
|
||||
</span>
|
||||
<span class="channel-trigger-caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +163,14 @@
|
||||
</div>
|
||||
<div id="sources-list" class="sources-list"></div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Captured Thumbnails</h4>
|
||||
<div class="setting-item">
|
||||
<label for="clear-posters-btn">Kept Pictures</label>
|
||||
<button id="clear-posters-btn" class="btn-secondary" type="button">Take them again</button>
|
||||
<p id="clear-posters-status" class="setting-note" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
|
||||
<div class="setting-item">
|
||||
@@ -175,7 +209,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>
|
||||
@@ -194,6 +231,19 @@
|
||||
|
||||
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
|
||||
|
||||
<div id="channel-picker" class="channel-picker" aria-hidden="true">
|
||||
<div class="channel-picker-box" role="dialog" aria-modal="true" aria-labelledby="channel-picker-title">
|
||||
<div class="channel-picker-head">
|
||||
<h3 id="channel-picker-title">Channel</h3>
|
||||
<button id="channel-picker-close" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<input id="channel-search" class="channel-search" type="text" placeholder="Search channels…"
|
||||
autocomplete="off" spellcheck="false" aria-controls="channel-picker-list">
|
||||
<div id="channel-picker-list" class="channel-picker-list" role="listbox" aria-label="Channels"></div>
|
||||
<div id="channel-picker-empty" class="channel-picker-empty">No channel matches that.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="command-palette" class="command-palette" aria-hidden="true">
|
||||
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
|
||||
@@ -201,6 +251,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="saved-panel" class="saved-panel" aria-hidden="true">
|
||||
<div class="saved-box" role="dialog" aria-modal="true" aria-labelledby="saved-panel-title">
|
||||
<div class="saved-head">
|
||||
<div class="saved-head-text">
|
||||
<h3 id="saved-panel-title">Saved searches</h3>
|
||||
<p class="saved-head-note" id="saved-head-note"></p>
|
||||
</div>
|
||||
<div class="saved-head-actions">
|
||||
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
|
||||
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
|
||||
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="saved-list" class="saved-list"></div>
|
||||
<div id="saved-empty" class="saved-empty">
|
||||
Nothing saved yet. Pick a channel, set your filters, type a search —
|
||||
then press the bookmark in the search bar to keep the whole thing.
|
||||
</div>
|
||||
<div class="saved-foot">
|
||||
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="privacy-veil" class="privacy-veil" aria-hidden="true"></div>
|
||||
|
||||
<script src="static/js/state.js"></script>
|
||||
<script src="static/js/marquee.js"></script>
|
||||
<script src="static/js/storage.js"></script>
|
||||
@@ -208,13 +284,16 @@
|
||||
<script src="static/js/player.js"></script>
|
||||
<script src="static/js/favorites.js"></script>
|
||||
<script src="static/js/favoritesView.js"></script>
|
||||
<script src="static/js/posters.js"></script>
|
||||
<script src="static/js/sqlite.js"></script>
|
||||
<script src="static/js/hottubBackup.js"></script>
|
||||
<script src="static/js/videos.js"></script>
|
||||
<script src="static/js/savedSearches.js"></script>
|
||||
<script src="static/js/feed.js"></script>
|
||||
<script src="static/js/ui.js"></script>
|
||||
<script src="static/js/version.js"></script>
|
||||
<script src="static/js/enhance.js"></script>
|
||||
<script src="static/js/privacy.js"></script>
|
||||
<script src="static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -194,36 +194,138 @@ App.customPlayer = App.customPlayer || {};
|
||||
// tab/app is backgrounded while a video is playing (Safari does this
|
||||
// natively for inline video; Chrome/Android need an explicit call).
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return !!(document.pictureInPictureEnabled);
|
||||
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
|
||||
// never implemented requestPictureInPicture, and exposes WebKit's older
|
||||
// presentation-mode switch instead. document.pictureInPictureEnabled is
|
||||
// undefined there, so every check below it used to answer "no" and the
|
||||
// button was hidden on the one platform where people most want it.
|
||||
//
|
||||
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
|
||||
// both, and bindPiPEvents re-fires WebKit's non-bubbling
|
||||
// webkitpresentationmodechanged as the standard enter/leave events, so the
|
||||
// delegated listeners elsewhere work unchanged.
|
||||
const WEBKIT_PIP = 'picture-in-picture';
|
||||
let webkitPipElement = null;
|
||||
|
||||
const standardPiP = () => !!document.pictureInPictureEnabled;
|
||||
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
|
||||
|
||||
// No element to ask about: does this browser have either API at all?
|
||||
let webkitProbe = null;
|
||||
const webkitAvailable = function() {
|
||||
if (webkitProbe === null) {
|
||||
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
|
||||
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
|
||||
webkitPiP(document.createElement('video')));
|
||||
}
|
||||
return webkitProbe;
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
// Deliberately the method's presence rather than
|
||||
// video.webkitSupportsPresentationMode(): that answers false until a video
|
||||
// track is loaded, and feed videos are preload="none" until they go active
|
||||
// -- it would hide the button on exactly the videos about to be able to
|
||||
// use it.
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return standardPiP() || webkitAvailable();
|
||||
};
|
||||
|
||||
App.customPlayer.pipElement = function() {
|
||||
return document.pictureInPictureElement || webkitPipElement || null;
|
||||
};
|
||||
|
||||
App.customPlayer.enterPiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (standardPiP()) {
|
||||
if (video.disablePictureInPicture) return false;
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!webkitPiP(video)) return false;
|
||||
try {
|
||||
// Synchronous, and it needs the user gesture that got us here.
|
||||
video.webkitSetPresentationMode(WEBKIT_PIP);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
App.customPlayer.exitPiP = async function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const video = webkitPipElement;
|
||||
if (!webkitPiP(video)) return;
|
||||
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
App.customPlayer.bindPiPEvents = function(video) {
|
||||
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
|
||||
// The same event announces fullscreen and inline, so only a real change
|
||||
// in picture-in-picture-ness is worth reporting.
|
||||
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
const onChange = function() {
|
||||
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
if (isPip === wasPip) return;
|
||||
wasPip = isPip;
|
||||
if (isPip) webkitPipElement = video;
|
||||
else if (webkitPipElement === video) webkitPipElement = null;
|
||||
video.dispatchEvent(new CustomEvent(
|
||||
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
|
||||
};
|
||||
video.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
return function destroy() {
|
||||
video.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
if (webkitPipElement === video) webkitPipElement = null;
|
||||
};
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (App.customPlayer.pipElement() === video) {
|
||||
await App.customPlayer.exitPiP();
|
||||
return true;
|
||||
}
|
||||
return App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
// Asking for picture-in-picture the moment a tab is hidden is a request
|
||||
// with no user gesture behind it, and browsers refuse those -- which is why
|
||||
// the imperative call below fails silently. `autoPictureInPicture` is the
|
||||
// declarative form made for exactly this: the browser is told in advance
|
||||
// which video should follow the reader out, and does it itself. Safari
|
||||
// honours it outright; Chrome honours it for installed apps. The call is
|
||||
// kept as a fallback for anywhere the flag is ignored but the request is
|
||||
// allowed.
|
||||
App.customPlayer.setAutoPiP = function(video, on) {
|
||||
if (!video) return;
|
||||
try { video.autoPictureInPicture = !!on; } catch (err) { /* unsupported */ }
|
||||
if (on) video.setAttribute('autopictureinpicture', '');
|
||||
else video.removeAttribute('autopictureinpicture');
|
||||
};
|
||||
|
||||
App.customPlayer.bindAutoPiP = function(video) {
|
||||
if (!video) return function destroy() {};
|
||||
App.customPlayer.setAutoPiP(video, true);
|
||||
const unbindEvents = App.customPlayer.bindPiPEvents(video);
|
||||
const trigger = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
|
||||
if (App.customPlayer.pipElement()) return;
|
||||
if (video.paused || video.ended) return;
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
App.customPlayer.enterPiP(video);
|
||||
};
|
||||
document.addEventListener('visibilitychange', trigger);
|
||||
window.addEventListener('pagehide', trigger);
|
||||
return function destroy() {
|
||||
App.customPlayer.setAutoPiP(video, false);
|
||||
unbindEvents();
|
||||
document.removeEventListener('visibilitychange', trigger);
|
||||
window.removeEventListener('pagehide', trigger);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,15 @@ App.enhance = App.enhance || {};
|
||||
if (!grid || !fineHover) return;
|
||||
let dwellTimer = null;
|
||||
let activeCard = null;
|
||||
// The card element alone doesn't identify what is being previewed: the
|
||||
// grid pools its cards, so the same element can come back showing a
|
||||
// different video (via relayout or a new search, neither of which
|
||||
// scrolls, so clearPreview never runs). Remembering the video too keeps
|
||||
// the "already previewing this" check honest.
|
||||
let activeVideo = null;
|
||||
|
||||
const isActive = (card) => card === activeCard &&
|
||||
(!App.videos.getVideoForCard || App.videos.getVideoForCard(card) === activeVideo);
|
||||
|
||||
const clearPreview = () => {
|
||||
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||
@@ -68,6 +77,7 @@ App.enhance = App.enhance || {};
|
||||
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||
activeCard.classList.remove('is-previewing');
|
||||
activeCard = null;
|
||||
activeVideo = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,10 +115,14 @@ App.enhance = App.enhance || {};
|
||||
|
||||
grid.addEventListener('pointerover', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card || card === activeCard) return;
|
||||
if (!card || isActive(card)) return;
|
||||
clearPreview();
|
||||
activeCard = card;
|
||||
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
|
||||
activeVideo = App.videos.getVideoForCard ? App.videos.getVideoForCard(card) : null;
|
||||
dwellTimer = setTimeout(() => {
|
||||
dwellTimer = null;
|
||||
if (isActive(card)) startPreview(card);
|
||||
}, 600);
|
||||
});
|
||||
grid.addEventListener('pointerout', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
@@ -171,6 +185,34 @@ App.enhance = App.enhance || {};
|
||||
});
|
||||
});
|
||||
}
|
||||
// Saved searches: the list itself, the one-press save, and every
|
||||
// saved search as a row of its own so ⌘K can jump straight to one.
|
||||
if (App.savedSearches && App.ui && App.ui.savedSearches) {
|
||||
const saved = App.savedSearches.getAll();
|
||||
out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() });
|
||||
const match = App.savedSearches.findMatching();
|
||||
out.push({
|
||||
label: match ? `Remove saved search: ${match.name}` : 'Save the current search',
|
||||
hint: 'Source · channel · filters · query',
|
||||
run: () => App.ui.savedSearches.toggleCurrent()
|
||||
});
|
||||
const total = App.savedSearches.totalNew();
|
||||
if (total) {
|
||||
out.push({
|
||||
label: 'Mark all saved searches read',
|
||||
hint: `${App.savedSearches.badgeLabel(total)} new`,
|
||||
run: () => App.savedSearches.markAllRead()
|
||||
});
|
||||
}
|
||||
saved.forEach((entry) => {
|
||||
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||
out.push({
|
||||
label: entry.name,
|
||||
hint: label ? `Saved search · ${label} new` : 'Saved search',
|
||||
run: () => App.savedSearches.apply(entry.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
||||
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
||||
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
||||
@@ -182,11 +224,19 @@ App.enhance = App.enhance || {};
|
||||
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
||||
});
|
||||
}
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
if (channelSelect) {
|
||||
Array.from(channelSelect.options).forEach((opt) => {
|
||||
if (opt.value === channelSelect.value) return;
|
||||
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
||||
// The channel list is the picker's (App.ui.channels), not a
|
||||
// <select>'s -- same entries, drawn as palette rows.
|
||||
if (App.ui && App.ui.channels) {
|
||||
const current = App.storage && App.storage.getSession ?
|
||||
App.storage.getSession() : null;
|
||||
const currentId = (current && current.channel) ? current.channel.id : '';
|
||||
App.ui.channels.entries().forEach((entry) => {
|
||||
if (entry.id === currentId) return;
|
||||
out.push({
|
||||
label: entry.label,
|
||||
hint: entry.group || 'Channel',
|
||||
run: () => App.ui.channels.choose(entry.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Both identities of every favorite, in one pass, held until the list
|
||||
// changes (see setAll). This is read once per card built, and re-parsing the
|
||||
// whole favorites list out of localStorage that often is what makes a long
|
||||
// list felt on a scrolling grid.
|
||||
let identityCache = null;
|
||||
|
||||
// Favorites storage helpers.
|
||||
App.favorites.getAll = function() {
|
||||
try {
|
||||
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
identityCache = null;
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
@@ -181,19 +188,49 @@ App.favorites = App.favorites || {};
|
||||
return { added, skipped, total: favorites.length };
|
||||
};
|
||||
|
||||
const identities = function() {
|
||||
if (!identityCache) {
|
||||
const keys = new Set();
|
||||
const urls = new Set();
|
||||
// getAll may rewrite the list (the repair pass), which clears the
|
||||
// cache -- so build it only after that has run.
|
||||
const items = App.favorites.getAll();
|
||||
items.forEach((item) => {
|
||||
if (!item) return;
|
||||
if (item.key) keys.add(item.key);
|
||||
const urlKey = App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
identityCache = { keys: keys, urls: urls };
|
||||
}
|
||||
return identityCache;
|
||||
};
|
||||
|
||||
App.favorites.getSet = function() {
|
||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
||||
return identities().keys;
|
||||
};
|
||||
|
||||
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
||||
// than by a server id, so a listing card can only recognise one this way.
|
||||
App.favorites.getUrlSet = function() {
|
||||
const urls = new Set();
|
||||
App.favorites.getAll().forEach((item) => {
|
||||
const urlKey = item && App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
return urls;
|
||||
return identities().urls;
|
||||
};
|
||||
|
||||
// Is this video a favorite, under either identity? A video reaches us from
|
||||
// the listing keyed by the server's id and from a Hot Tub backup keyed by
|
||||
// its URL, and the same video must light up its heart whichever way the
|
||||
// copy on disk got there.
|
||||
App.favorites.has = function(video) {
|
||||
const index = identities();
|
||||
const key = App.favorites.getKey(video);
|
||||
if (key && index.keys.has(key)) return true;
|
||||
// Normalising a URL means parsing one, which is the expensive half of
|
||||
// this and runs for every card that isn't a favorite by key. With
|
||||
// nothing stored to match against, there is nothing to parse it for.
|
||||
if (!index.urls.size) return false;
|
||||
const meta = (video && video.meta) || video || {};
|
||||
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
||||
return !!(urlKey && index.urls.has(urlKey));
|
||||
};
|
||||
|
||||
// Is this video already a favorite, whichever way it got saved? Checked by
|
||||
@@ -209,6 +246,58 @@ App.favorites = App.favorites || {};
|
||||
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
||||
};
|
||||
|
||||
// Everything an entry carries, so a change can be told from a no-op.
|
||||
const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel',
|
||||
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
||||
|
||||
const sameEntry = function(a, b) {
|
||||
return ENTRY_FIELDS.every((field) =>
|
||||
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
||||
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
||||
};
|
||||
|
||||
// Brings stored favorites up to date from a page of listing videos.
|
||||
//
|
||||
// The same video reaches this client under two identities: saved from a
|
||||
// card it carries the server's id, imported from a Hot Tub backup it
|
||||
// carries only its URL. Matching on the URL is what recognises them as one
|
||||
// thing -- and once they are matched, the listing's copy is the better one.
|
||||
// It has the id every card keys on, and a thumbnail URL that hasn't been
|
||||
// sitting in localStorage since whenever the backup was taken. So the
|
||||
// stored entry is replaced by it, keeping only the date it was first saved:
|
||||
// that is the one fact the listing doesn't know and the favorites sort
|
||||
// depends on.
|
||||
//
|
||||
// Writes only when something actually differs, so the steady state of
|
||||
// scrolling a listing full of favorites is no writes at all.
|
||||
App.favorites.reconcile = function(videos) {
|
||||
const items = Array.isArray(videos) ? videos : [];
|
||||
if (!items.length) return 0;
|
||||
const favorites = App.favorites.getAll();
|
||||
if (!favorites.length) return 0;
|
||||
|
||||
let changed = 0;
|
||||
items.forEach((video) => {
|
||||
if (!video || !video.url) return;
|
||||
const index = App.favorites.indexOfEntry(favorites, video);
|
||||
if (index < 0) return;
|
||||
const existing = favorites[index];
|
||||
const upgraded = App.favorites.normalize(Object.assign({}, video, {
|
||||
favoriteDate: existing.favoriteDate
|
||||
}));
|
||||
if (!upgraded || sameEntry(existing, upgraded)) return;
|
||||
favorites[index] = upgraded;
|
||||
changed++;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
App.favorites.setAll(favorites);
|
||||
App.favorites.renderBar();
|
||||
App.favorites.syncButtons();
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
App.favorites.isVisible = function() {
|
||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||
};
|
||||
@@ -405,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), item);
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
|
||||
1622
frontend/js/feed.js
1622
frontend/js/feed.js
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ window.App = window.App || {};
|
||||
(function() {
|
||||
// App bootstrap: initialize storage, render UI, and load the first page.
|
||||
async function initApp() {
|
||||
// Armed before anything is on screen: there is nothing to show behind
|
||||
// a blur yet, but the window can lose focus during startup too.
|
||||
App.privacy.start();
|
||||
|
||||
await App.storage.ensureDefaults();
|
||||
App.ui.applyTheme();
|
||||
App.ui.applyPreferredQuality();
|
||||
@@ -39,6 +43,12 @@ window.App = window.App || {};
|
||||
await App.videos.loadVideos();
|
||||
App.favorites.syncButtons();
|
||||
|
||||
// The grid is showing something now. If that something is one of the
|
||||
// saved searches, the reader is looking at it -- so it stops being
|
||||
// "new" from here, and the once-a-day check starts running.
|
||||
App.savedSearches.syncViewed();
|
||||
App.savedSearches.start();
|
||||
|
||||
// The UI above is rendered entirely from the last known status cached in
|
||||
// localStorage, so startup never blocks on (or breaks because of) a slow
|
||||
// or failing status endpoint. Now fetch fresh status in the background and
|
||||
|
||||
@@ -23,12 +23,25 @@ App.player = App.player || {};
|
||||
historyPushed: false,
|
||||
idleTimer: null,
|
||||
originEl: null,
|
||||
originToken: null, // stamp proving originEl is still the card we opened
|
||||
hudHovered: false, // mouse resting on the controls (desktop)
|
||||
activeUrl: '', // media URL actually playing, for the format menu's tick
|
||||
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
||||
fetchAbort: null // aborts the current attempt's own requests
|
||||
fetchAbort: null, // aborts the current attempt's own requests
|
||||
pending: false, // loading off-screen, not shown yet (see reveal)
|
||||
revealTimer: null,
|
||||
mutedBeforeReveal: null
|
||||
};
|
||||
|
||||
// A session exists: either on screen, or still loading in the background
|
||||
// before it gets there. Everything that used to ask "is the player open?"
|
||||
// means this -- a pending session owns the same history entry, the same
|
||||
// card spinner and the same in-flight requests as a shown one.
|
||||
function isActive() {
|
||||
return !!(cp.container && (cp.pending || cp.container.classList.contains('open')));
|
||||
}
|
||||
App.player.isActive = isActive;
|
||||
|
||||
// Stops everything the current attempt has in flight. The token guards keep
|
||||
// stale *callbacks* from acting, but they don't stop the requests those
|
||||
// callbacks were waiting on: hls.js goes on pulling segments through the
|
||||
@@ -59,6 +72,28 @@ App.player = App.player || {};
|
||||
}
|
||||
}
|
||||
|
||||
// The card the player was opened from is owned by the grid, which pools and
|
||||
// reuses its cards (see resetCard in videos.js). By the time the player lets
|
||||
// go, that element may be showing a different video -- so it is stamped at
|
||||
// open, and every later touch checks the stamp still matches. A recycled
|
||||
// card has had it wiped, and simply stops answering.
|
||||
let originSeq = 0;
|
||||
|
||||
const claimOrigin = function(el) {
|
||||
if (!el) return null;
|
||||
const token = String(++originSeq);
|
||||
el.dataset.playerToken = token;
|
||||
return token;
|
||||
};
|
||||
|
||||
const withOrigin = function(el, token, fn) {
|
||||
// `token` must be truthy in its own right: dataset yields undefined for
|
||||
// a missing attribute, so without this an unstamped token would match
|
||||
// every card that has no stamp -- including one the grid has recycled,
|
||||
// which is precisely the case this guard exists to catch.
|
||||
if (el && token && el.dataset.playerToken === token) fn(el);
|
||||
};
|
||||
|
||||
const addCleanup = (fn) => cp.cleanups.push(fn);
|
||||
const runCleanups = () => {
|
||||
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||
@@ -295,7 +330,7 @@ App.player = App.player || {};
|
||||
const key = App.favorites.getKey(videoData);
|
||||
if (!key) { btn.hidden = true; return; }
|
||||
btn.dataset.favKey = key;
|
||||
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
|
||||
App.favorites.setButtonState(btn, App.favorites.has(videoData));
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(videoData);
|
||||
@@ -542,7 +577,10 @@ App.player = App.player || {};
|
||||
// ---------------------------------------------------------------------
|
||||
function bindKeyboard(video) {
|
||||
const onKeyDown = (event) => {
|
||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||
if (!isActive()) return;
|
||||
// Nothing else is worth doing to a video nobody can see yet, but
|
||||
// changing your mind about it is.
|
||||
if (cp.pending && event.key !== 'Escape') return;
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
@@ -592,7 +630,7 @@ App.player = App.player || {};
|
||||
addCleanup(() => closeBtn.removeEventListener('click', onClick));
|
||||
}
|
||||
const onPopState = () => {
|
||||
if (cp.container && cp.container.classList.contains('open')) {
|
||||
if (isActive()) {
|
||||
cp.historyPushed = false; // the pushed state was just consumed by the browser
|
||||
App.player.close({ fromPopState: true });
|
||||
}
|
||||
@@ -634,7 +672,64 @@ App.player = App.player || {};
|
||||
if (spinner) spinner.classList.toggle('is-visible', show);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Going on screen
|
||||
//
|
||||
// Opening a video used to put an empty black player up immediately and
|
||||
// spin at the viewer until the first frame arrived -- which, between
|
||||
// resolving the formats and the first bytes of a stream, is routinely a
|
||||
// couple of seconds of nothing. So the session now starts off-screen:
|
||||
// the card that was clicked keeps its own spinner, the page stays where
|
||||
// it was, and the player appears only once the video has real data to
|
||||
// show. Failures reveal it too -- the error and its retry live inside the
|
||||
// player -- and so does the timeout below, because a stream that is merely
|
||||
// slow is better watched from inside the player (which can be closed) than
|
||||
// from a card that looks stuck.
|
||||
// ---------------------------------------------------------------------
|
||||
const REVEAL_TIMEOUT_MS = 5000;
|
||||
|
||||
function reveal() {
|
||||
if (!cp.container) return;
|
||||
if (cp.revealTimer) {
|
||||
clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = null;
|
||||
}
|
||||
cp.pending = false;
|
||||
// The card has handed over, whether this is the first reveal or a
|
||||
// second video opened over the top of the first.
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||
if (cp.container.classList.contains('open')) return;
|
||||
|
||||
cp.container.classList.remove('is-preloading');
|
||||
cp.container.classList.add('open');
|
||||
cp.container.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Muted while it was loading out of sight; it is in sight now.
|
||||
if (cp.mutedBeforeReveal !== null && cp.video) {
|
||||
cp.video.muted = cp.mutedBeforeReveal;
|
||||
cp.mutedBeforeReveal = null;
|
||||
}
|
||||
wakeHud();
|
||||
}
|
||||
|
||||
// The first data is the cue: `loadeddata` means a frame can be drawn, and
|
||||
// `playing` covers the sources that get there without one (audio-only, and
|
||||
// anything whose first frame lands before the listener is attached).
|
||||
function bindReveal(video) {
|
||||
const onData = () => reveal();
|
||||
video.addEventListener('loadeddata', onData);
|
||||
video.addEventListener('playing', onData);
|
||||
addCleanup(() => {
|
||||
video.removeEventListener('loadeddata', onData);
|
||||
video.removeEventListener('playing', onData);
|
||||
});
|
||||
if (video.readyState >= 2) reveal();
|
||||
}
|
||||
|
||||
function showError(message, onRetry, sourceUrl) {
|
||||
// Whatever went wrong, it says so in the player -- which the viewer
|
||||
// can only read if the player is on screen.
|
||||
reveal();
|
||||
showBuffering(false);
|
||||
const errorEl = q('.cp-error');
|
||||
const textEl = q('.cp-error-text');
|
||||
@@ -695,9 +790,10 @@ App.player = App.player || {};
|
||||
// the wrong card loaded or (via the token guard below) never clear
|
||||
// this card's spinner at all.
|
||||
const originEl = (opts && opts.originEl) || null;
|
||||
const originToken = originEl ? originEl.dataset.playerToken : null;
|
||||
const sources = resolveSources(videoData);
|
||||
const clearLoading = () => {
|
||||
if (originEl) originEl.classList.remove('is-loading');
|
||||
withOrigin(originEl, originToken, (el) => el.classList.remove('is-loading'));
|
||||
};
|
||||
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
|
||||
|
||||
@@ -734,7 +830,7 @@ App.player = App.player || {};
|
||||
// a browser, so there is nothing to find out.
|
||||
if (resolved.refererRequired) return;
|
||||
if (directProven(resolved.url)) return;
|
||||
App.videos.probeDirect(resolved.url).then((ok) => {
|
||||
App.videos.probeDirect(resolved).then((ok) => {
|
||||
if (!ok || token !== cp.attemptToken) return;
|
||||
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
|
||||
// playing video alone rather than trading a visible stall for a
|
||||
@@ -830,7 +926,6 @@ App.player = App.player || {};
|
||||
// Whichever candidate got this far is the one on screen -- not
|
||||
// necessarily the one the ranking (or the viewer) asked for.
|
||||
cp.activeUrl = resolved.url || '';
|
||||
clearLoading();
|
||||
hideError();
|
||||
showBuffering(false);
|
||||
if (resumeAt > 0) {
|
||||
@@ -916,16 +1011,17 @@ App.player = App.player || {};
|
||||
// single history.back() could never fully unwind. Also clears the
|
||||
// abandoned session's own loading spinner, since its card would
|
||||
// otherwise never hear about the takeover.
|
||||
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
||||
const reopening = isActive();
|
||||
if (reopening) {
|
||||
cp.attemptToken++;
|
||||
cancelInFlight();
|
||||
clearIdleTimer();
|
||||
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||
}
|
||||
runCleanups();
|
||||
|
||||
cp.originEl = opts && opts.originEl ? opts.originEl : null;
|
||||
cp.originToken = claimOrigin(cp.originEl);
|
||||
if (cp.originEl) cp.originEl.classList.add('is-loading');
|
||||
|
||||
cp.container = buildContainer();
|
||||
@@ -997,11 +1093,21 @@ App.player = App.player || {};
|
||||
bindKeyboard(cp.video);
|
||||
bindClose();
|
||||
bindBufferingIndicator(cp.video);
|
||||
bindReveal(cp.video);
|
||||
|
||||
cp.container.classList.add('open');
|
||||
cp.container.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
wakeHud();
|
||||
// Loading out of sight: laid out (so the media element behaves as it
|
||||
// would on screen -- iOS in particular will not load a display:none
|
||||
// video) but transparent and untouchable, so the page underneath is
|
||||
// still the page the viewer is using.
|
||||
if (!cp.container.classList.contains('open')) {
|
||||
cp.pending = true;
|
||||
cp.container.classList.add('is-preloading');
|
||||
// Nothing should be heard from a player that isn't there yet.
|
||||
cp.mutedBeforeReveal = cp.video.muted;
|
||||
cp.video.muted = true;
|
||||
if (cp.revealTimer) clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Already-resolved sources start immediately; unresolved ones start from
|
||||
// the ensureFormats() callback above (the spinner is already up).
|
||||
@@ -1009,7 +1115,7 @@ App.player = App.player || {};
|
||||
};
|
||||
|
||||
App.player.close = function(opts) {
|
||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||
if (!isActive()) return;
|
||||
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
||||
// Closing the player must also stop what it was fetching -- otherwise a
|
||||
// proxied stream keeps being pulled, and the server keeps an upstream
|
||||
@@ -1018,7 +1124,13 @@ App.player = App.player || {};
|
||||
clearIdleTimer();
|
||||
runCleanups();
|
||||
|
||||
cp.container.classList.remove('open', 'cp-hud-idle', 'is-live');
|
||||
if (cp.revealTimer) {
|
||||
clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = null;
|
||||
}
|
||||
cp.pending = false;
|
||||
cp.mutedBeforeReveal = null;
|
||||
cp.container.classList.remove('open', 'is-preloading', 'cp-hud-idle', 'is-live');
|
||||
cp.container.style.transform = '';
|
||||
cp.container.style.opacity = '';
|
||||
cp.container.setAttribute('aria-hidden', 'true');
|
||||
@@ -1031,10 +1143,12 @@ App.player = App.player || {};
|
||||
cp.historyPushed = false;
|
||||
}
|
||||
|
||||
if (cp.originEl) {
|
||||
cp.originEl.classList.remove('is-loading');
|
||||
cp.originEl = null;
|
||||
}
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => {
|
||||
el.classList.remove('is-loading');
|
||||
delete el.dataset.playerToken;
|
||||
});
|
||||
cp.originEl = null;
|
||||
cp.originToken = null;
|
||||
cp.data = null;
|
||||
cp.source = null; // voids a still-pending format resolve for this open
|
||||
cp.formatOverride = null;
|
||||
|
||||
583
frontend/js/posters.js
Normal file
583
frontend/js/posters.js
Normal file
@@ -0,0 +1,583 @@
|
||||
window.App = window.App || {};
|
||||
App.posters = App.posters || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured posters
|
||||
//
|
||||
// A favorite outlives its thumbnail. The picture a listing hands out is a
|
||||
// signed CDN path on most sources, and the signature expires -- so a video kept
|
||||
// months ago has a `thumb` that 404s, and the card that stood for it is a grey
|
||||
// box with a title under it. Asking the source page for a fresh one (the
|
||||
// /api/poster repair in videos.js) covers the case where the page still exists
|
||||
// and still shows a picture; nothing covers the case where it doesn't.
|
||||
//
|
||||
// The video itself does, though: it is still playable, 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 into the clip becomes the
|
||||
// card's picture -- far enough in to be past a title card or a black lead-in,
|
||||
// and a fair guess at what the video looks like.
|
||||
//
|
||||
// A frame costs a resolve and the stretch of video around that point, which is
|
||||
// why this happens once per item, for favorites only, and never again: the
|
||||
// frame is scaled down, JPEG'd, and kept in localStorage, so the next mount and
|
||||
// every later session paint it straight away without touching the network.
|
||||
//
|
||||
// Which is also why a capture has to be looked at before it is kept. A frame
|
||||
// with nothing in it -- an element that decoded nothing draws as nothing, and a
|
||||
// lead-in or a fade is black in its own right -- is a black box on the card for
|
||||
// the life of the item, and asking again is exactly what the cache is there to
|
||||
// avoid. So a blank frame is taken somewhere else in the clip instead (see
|
||||
// planFor), and one kept by a version of this that did not check gets measured
|
||||
// once and replaced (see recheck).
|
||||
// ---------------------------------------------------------------------------
|
||||
(function() {
|
||||
const INDEX_KEY = App.constants.POSTER_CACHE_KEY;
|
||||
const ENTRY_PREFIX = `${INDEX_KEY}:`;
|
||||
// Marks an entry whose picture was checked for having a picture in it,
|
||||
// which every capture now is and none of the older ones were.
|
||||
const CHECKED = 1;
|
||||
|
||||
// A thumbnail box is around 300 CSS px wide in the grid and half a phone
|
||||
// screen in the favorites bar, so this is a picture at roughly card size
|
||||
// and not a frame of video: the point of the cache is to be small.
|
||||
const WIDTH = 360;
|
||||
const QUALITY = 0.72;
|
||||
// How far into the clip the frame comes from.
|
||||
const SEEK_FRACTION = 0.25;
|
||||
// What the whole cache may take. Stored base64, so the string is a third
|
||||
// larger than the JPEG behind it -- around 20 KB an entry, which leaves
|
||||
// room for a hundred or so of them inside a localStorage budget that is
|
||||
// shared with the favorites themselves.
|
||||
const BUDGET_BYTES = 3 * 1024 * 1024;
|
||||
// How long a seek gets before the frame it was decoding anyway will do.
|
||||
const SEEK_PATIENCE_MS = 6000;
|
||||
// How long a frame gets to be presented once the data for it has arrived.
|
||||
const PRESENT_PATIENCE_MS = 500;
|
||||
// A capture is a handful of seeks into one file. If none of them has
|
||||
// produced a picture by now, the source is not going to give us one.
|
||||
const FRAME_TIMEOUT_MS = 30000;
|
||||
// How bright the brightest sample in a frame has to be for the frame to be
|
||||
// a picture of anything at all. A floor, not a threshold: this is the
|
||||
// difference between a frame and no frame, not between a dark scene and a
|
||||
// bright one.
|
||||
const BLACK_LEVEL = 10;
|
||||
// Every fourth pixel answers that question, and keeps the check off the
|
||||
// cost of a capture.
|
||||
const SAMPLE_STRIDE = 4;
|
||||
// Where to look in a clip whose length nobody will say.
|
||||
const BLIND_MARK_SECONDS = 10;
|
||||
// How many of the ranked sources are worth trying before giving up on an
|
||||
// item for this session.
|
||||
const SOURCE_TRIES = 2;
|
||||
|
||||
// Both halves of a 64-bit-ish hash of the item's identity: a cache key has
|
||||
// to be short enough to be a localStorage key and distinct enough that two
|
||||
// favorites never share a picture.
|
||||
const hash = function(text) {
|
||||
let fnv = 0x811c9dc5;
|
||||
let djb = 5381;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
fnv = (fnv ^ code) >>> 0;
|
||||
fnv = (fnv + ((fnv << 1) + (fnv << 4) + (fnv << 7) + (fnv << 8) + (fnv << 24))) >>> 0;
|
||||
djb = (((djb << 5) + djb) + code) >>> 0;
|
||||
}
|
||||
return fnv.toString(16).padStart(8, '0') + djb.toString(16).padStart(8, '0');
|
||||
};
|
||||
|
||||
// The identity to cache under: the item's own URL, normalized the way
|
||||
// favorites normalize one, so the same video saved from a listing and
|
||||
// imported from a backup lands on the same picture.
|
||||
const keyOf = function(video) {
|
||||
if (!video || typeof video !== 'object') return '';
|
||||
const url = video.url || (video.meta && video.meta.url) || '';
|
||||
const basis = (url && App.favorites && App.favorites.urlKey(url)) || url ||
|
||||
(App.favorites && App.favorites.getKey(video)) || video.id || '';
|
||||
return basis ? hash(String(basis)) : '';
|
||||
};
|
||||
|
||||
// ---- the cache -------------------------------------------------------
|
||||
|
||||
// [{ k, n, at }], oldest capture first. Read once and kept: a card asks
|
||||
// whether it has a picture as it mounts, and parsing the index again for
|
||||
// every card of a scrolling grid is the one thing this must not cost.
|
||||
let index = null;
|
||||
|
||||
const readIndex = function() {
|
||||
if (index) return index;
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(INDEX_KEY));
|
||||
index = Array.isArray(parsed) ?
|
||||
parsed.filter((entry) => entry && entry.k).map((entry) => ({
|
||||
k: String(entry.k),
|
||||
n: Number(entry.n) || 0,
|
||||
at: Number(entry.at) || 0,
|
||||
// Whether anything ever looked at what this entry holds.
|
||||
// Absent on everything written before that was checked --
|
||||
// see recheck.
|
||||
v: Number(entry.v) || 0
|
||||
})) : [];
|
||||
} catch (err) {
|
||||
index = [];
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const writeIndex = function() {
|
||||
try {
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index));
|
||||
} catch (err) { /* nothing here is worth failing a card over */ }
|
||||
};
|
||||
|
||||
const drop = function(entry) {
|
||||
try {
|
||||
localStorage.removeItem(ENTRY_PREFIX + entry.k);
|
||||
} catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
const store = function(key, src) {
|
||||
const list = readIndex().filter((entry) => entry.k !== key);
|
||||
list.push({ k: key, n: src.length, at: Date.now(), v: CHECKED });
|
||||
index = list;
|
||||
// Oldest captures go first. Not least-recently-seen: tracking that
|
||||
// would mean writing to storage every time a card mounts, which is a
|
||||
// worse trade than occasionally re-capturing a picture.
|
||||
let total = list.reduce((sum, entry) => sum + entry.n, 0);
|
||||
while (total > BUDGET_BYTES && index.length > 1) {
|
||||
const oldest = index.shift();
|
||||
total -= oldest.n;
|
||||
drop(oldest);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + key, src);
|
||||
} catch (err) {
|
||||
// Out of room, and not necessarily because of us -- the store is
|
||||
// shared with the favorites and everything else. Make room by
|
||||
// giving up the oldest third of the cache and try once more.
|
||||
const giveUp = Math.max(1, Math.floor(index.length / 3));
|
||||
index.splice(0, giveUp).forEach(drop);
|
||||
try {
|
||||
localStorage.setItem(ENTRY_PREFIX + key, src);
|
||||
} catch (again) {
|
||||
index = index.filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
return '';
|
||||
}
|
||||
}
|
||||
writeIndex();
|
||||
return src;
|
||||
};
|
||||
|
||||
const read = function(key) {
|
||||
try {
|
||||
return localStorage.getItem(ENTRY_PREFIX + key) || '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// The picture already captured for this item, if there is one.
|
||||
App.posters.cached = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return '';
|
||||
// The index says what we have without touching the entries, so an item
|
||||
// with no captured picture -- almost all of them -- costs a set lookup
|
||||
// rather than a read of a data URL out of storage.
|
||||
if (!readIndex().some((entry) => entry.k === key)) return '';
|
||||
const src = read(key);
|
||||
if (!src) {
|
||||
index = readIndex().filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
}
|
||||
return src;
|
||||
};
|
||||
|
||||
App.posters.forget = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return;
|
||||
readIndex().filter((entry) => entry.k === key).forEach(drop);
|
||||
index = readIndex().filter((entry) => entry.k !== key);
|
||||
writeIndex();
|
||||
};
|
||||
|
||||
// Throws every captured picture away, and with it this session's memory of
|
||||
// which items there was no point asking about. That memory is the whole
|
||||
// reason a dead favorite is cheap to scroll past, so it only goes when the
|
||||
// reader asks for the pictures to be taken again (Settings -> Captured
|
||||
// Thumbnails) -- otherwise an item that failed once would be retried on
|
||||
// every mount for the rest of the session.
|
||||
App.posters.clear = function() {
|
||||
readIndex().forEach(drop);
|
||||
index = [];
|
||||
writeIndex();
|
||||
failed.clear();
|
||||
rechecks.clear();
|
||||
};
|
||||
|
||||
App.posters.stats = function() {
|
||||
const list = readIndex();
|
||||
return { count: list.length, bytes: list.reduce((sum, entry) => sum + entry.n, 0) };
|
||||
};
|
||||
|
||||
// ---- taking the frame ------------------------------------------------
|
||||
|
||||
// Is there a picture in what was just drawn?
|
||||
//
|
||||
// Two things come out of a capture looking identical, and both are useless.
|
||||
// A video that has decoded nothing draws as nothing: the canvas keeps its
|
||||
// transparent black, which becomes a pure black JPEG about 1.6 KB long --
|
||||
// large enough to pass for a picture, and kept for the life of the item.
|
||||
// And a frame that genuinely is black (a lead-in, a fade, a cut between
|
||||
// scenes) is no better a thumbnail than that one. The answer to both is to
|
||||
// take the frame somewhere else, so they are the same question.
|
||||
const isBlank = function(ctx, width, height) {
|
||||
// Tainted canvas throws here, which is why the frame is taken from our
|
||||
// own proxy rather than from the CDN: /api/stream is same-origin, so
|
||||
// the pixels can be read back.
|
||||
const px = ctx.getImageData(0, 0, width, height).data;
|
||||
let painted = false;
|
||||
let brightest = 0;
|
||||
for (let i = 0; i < px.length; i += 4 * SAMPLE_STRIDE) {
|
||||
if (px[i + 3] > 0) painted = true;
|
||||
const value = Math.max(px[i], px[i + 1], px[i + 2]);
|
||||
if (value > brightest) brightest = value;
|
||||
}
|
||||
return !painted || brightest <= BLACK_LEVEL;
|
||||
};
|
||||
|
||||
// Draws what the element is showing, at card size rather than video size.
|
||||
// Empty when there turns out to be nothing in it, which is the caller's cue
|
||||
// to look elsewhere in the clip rather than keep it.
|
||||
const drawFrame = function(el) {
|
||||
const width = el.videoWidth;
|
||||
const height = el.videoHeight;
|
||||
if (!width || !height) throw new Error('nothing was decoded');
|
||||
const scale = Math.min(1, WIDTH / width);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width * scale));
|
||||
canvas.height = Math.max(1, Math.round(height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(el, 0, 0, canvas.width, canvas.height);
|
||||
if (isBlank(ctx, canvas.width, canvas.height)) return '';
|
||||
const src = canvas.toDataURL('image/jpeg', QUALITY);
|
||||
if (!src || src.length < 512) throw new Error('the frame came out empty');
|
||||
return src;
|
||||
};
|
||||
|
||||
// Where a frame comes from, and what to do when it comes back blank. A
|
||||
// blank frame means one of two things and each has its own answer: the
|
||||
// element decoded nothing (so play it for a moment -- an element that is
|
||||
// playing has to present frames, which a seeked one is under no obligation
|
||||
// to do), or the frame at that mark really is black (so take it somewhere
|
||||
// else).
|
||||
const planFor = function(length) {
|
||||
if (length > 0 && isFinite(length)) {
|
||||
return [
|
||||
{ at: length * SEEK_FRACTION, play: false },
|
||||
{ at: length * SEEK_FRACTION, play: true },
|
||||
{ at: length * 0.5, play: true }
|
||||
];
|
||||
}
|
||||
// Nobody will say how long this is: an extractor that reports no
|
||||
// duration, a file the browser won't commit to a length for. A blind
|
||||
// seek to a fixed mark still beats the opening frame, which is a black
|
||||
// one often enough to be half the reason this check exists -- and a
|
||||
// mark past the end comes back clamped into the file, so the guess
|
||||
// costs nothing.
|
||||
return [
|
||||
{ at: BLIND_MARK_SECONDS, play: false },
|
||||
{ at: 0, play: true }
|
||||
];
|
||||
};
|
||||
|
||||
const grabFrame = function(url, duration) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const el = document.createElement('video');
|
||||
el.muted = true;
|
||||
el.defaultMuted = true;
|
||||
el.playsInline = true;
|
||||
el.preload = 'auto';
|
||||
el.crossOrigin = 'anonymous';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
// Off-screen, but in the document: an element the browser does not
|
||||
// consider part of a page is under no obligation to decode
|
||||
// anything.
|
||||
el.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;' +
|
||||
'opacity:0;pointer-events:none';
|
||||
|
||||
let settled = false;
|
||||
let budget = null;
|
||||
let patience = null;
|
||||
// The marks to try, and which of them we are on.
|
||||
let plan = [];
|
||||
let step = -1;
|
||||
// Where we asked to be, and whether we asked at all. A browser
|
||||
// fires `seeked` for its own initial seek to zero as the file
|
||||
// opens, and taking the frame on that one would mean every
|
||||
// captured picture is the first frame of the video -- usually a
|
||||
// black one -- however far in we meant to go.
|
||||
let target = 0;
|
||||
let asked = false;
|
||||
// A frame is already being waited for. A completed seek and the
|
||||
// patience timer can both decide it is time, and the second one to
|
||||
// decide must not start a second attempt on the same frame.
|
||||
let pending = false;
|
||||
|
||||
const done = function(err, src) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (budget) clearTimeout(budget);
|
||||
if (patience) clearTimeout(patience);
|
||||
try { el.pause(); } catch (stop) { /* never started */ }
|
||||
// Told to stop downloading before it leaves, or the request
|
||||
// outlives the element that made it.
|
||||
el.removeAttribute('src');
|
||||
try { el.load(); } catch (stop) { /* nothing to stop */ }
|
||||
el.remove();
|
||||
if (err) reject(err);
|
||||
else resolve(src);
|
||||
};
|
||||
|
||||
const take = function() {
|
||||
pending = false;
|
||||
let src = '';
|
||||
try {
|
||||
src = drawFrame(el);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
return;
|
||||
}
|
||||
if (src) {
|
||||
done(null, src);
|
||||
return;
|
||||
}
|
||||
// Nothing in it, so this mark is spent.
|
||||
attempt();
|
||||
};
|
||||
|
||||
// Drawing an element that has not presented a frame gives an empty
|
||||
// canvas, and the engines disagree about when that is: Chromium has
|
||||
// a decoded frame by the time it says a seek is done, while Safari
|
||||
// will not present one for an off-screen paused element until it
|
||||
// has to. `requestVideoFrameCallback` is the browser saying a frame
|
||||
// is on screen now, which is the only direct answer there is; where
|
||||
// there is none, a decoded readyState after a paint is the nearest
|
||||
// thing to one.
|
||||
const whenPresented = function() {
|
||||
if (settled || pending) return;
|
||||
pending = true;
|
||||
let taken = false;
|
||||
const once = function() {
|
||||
if (taken || settled) return;
|
||||
taken = true;
|
||||
take();
|
||||
};
|
||||
if (typeof el.requestVideoFrameCallback === 'function') {
|
||||
el.requestVideoFrameCallback(() => once());
|
||||
// An element that is paused on the frame it already
|
||||
// presented may never present another, so the callback on
|
||||
// its own would wait for good.
|
||||
setTimeout(() => {
|
||||
if (el.readyState >= 2) once();
|
||||
else pending = false;
|
||||
}, PRESENT_PATIENCE_MS);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (el.readyState >= 2) once();
|
||||
else setTimeout(once, PRESENT_PATIENCE_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const attempt = function() {
|
||||
step += 1;
|
||||
if (step >= plan.length) {
|
||||
done(new Error('every frame came out blank'));
|
||||
return;
|
||||
}
|
||||
if (patience) clearTimeout(patience);
|
||||
const mark = plan[step];
|
||||
target = mark.at;
|
||||
if (mark.play) {
|
||||
const playing = el.play();
|
||||
if (playing && typeof playing.catch === 'function') playing.catch(() => {});
|
||||
}
|
||||
if (target > 0 && Math.abs(el.currentTime - target) > 1) {
|
||||
asked = true;
|
||||
try { el.currentTime = target; } catch (err) { done(err); return; }
|
||||
} else {
|
||||
// Already where this attempt wanted to be: all that has
|
||||
// changed is that the element is now playing.
|
||||
whenPresented();
|
||||
}
|
||||
// Some sources cannot be seeked in at all: no byte ranges, or
|
||||
// a length the browser does not trust. They still have an
|
||||
// opening frame, and a picture of the opening beats a grey box
|
||||
// -- but only once the seek has had its chance.
|
||||
patience = setTimeout(() => {
|
||||
if (el.readyState >= 2) whenPresented();
|
||||
}, SEEK_PATIENCE_MS);
|
||||
};
|
||||
|
||||
budget = setTimeout(() => done(new Error('no frame in time')), FRAME_TIMEOUT_MS);
|
||||
el.addEventListener('error', () => done(new Error('the source would not play')));
|
||||
el.addEventListener('loadedmetadata', () => {
|
||||
plan = planFor(Number(el.duration) || Number(duration) || 0);
|
||||
step = -1;
|
||||
attempt();
|
||||
});
|
||||
el.addEventListener('seeked', () => {
|
||||
// Not ours: the browser's own seek to zero as the file opens.
|
||||
if (!asked || (target > 0 && el.currentTime <= 0)) return;
|
||||
// Near enough: a seek lands on the nearest frame it can decode,
|
||||
// which is the keyframe before the mark rather than the mark
|
||||
// itself, and a mark past the end comes back clamped to as
|
||||
// close as that file gets.
|
||||
whenPresented();
|
||||
});
|
||||
|
||||
document.body.appendChild(el);
|
||||
el.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const captureOne = async function(video) {
|
||||
// Favorites keep no resolved formats on purpose (their URLs expire), so
|
||||
// this is where the item becomes playable again.
|
||||
try {
|
||||
await App.videos.ensureFormats(video);
|
||||
} catch (err) { /* the page-URL fallback below may still play */ }
|
||||
|
||||
const sources = App.videos.resolveStreamSources(video, {
|
||||
applyPreferredQuality: false,
|
||||
// The smallest format there is: this is going to end up 360 px
|
||||
// wide, so a 1080p stream would be bytes spent on pixels that get
|
||||
// thrown away.
|
||||
cheapest: true
|
||||
}).filter((source) => {
|
||||
// A manifest is a playlist of segments, not something a bare
|
||||
// <video> can open outside Safari, and the player's hls.js is a
|
||||
// heavier thing than a thumbnail is worth.
|
||||
const kind = App.videos.classifySource(source);
|
||||
return !kind.isHls && !source.isLive;
|
||||
}).slice(0, SOURCE_TRIES);
|
||||
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const url = App.videos.buildStreamUrlFromSource(sources[i]);
|
||||
if (!url) continue;
|
||||
try {
|
||||
return await grabFrame(url, Number(video.duration) || 0);
|
||||
} catch (err) {
|
||||
// Try the next format; if that was the last one, the item has
|
||||
// no picture and no amount of asking changes it.
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// The same item is on screen in more than one place -- the favorites bar
|
||||
// and the grid behind it, a card and the reel's own poster -- and all of
|
||||
// them ask. They wait on the one capture and all get the picture; the
|
||||
// alternative is the first one to ask getting a picture and the rest being
|
||||
// told there isn't one.
|
||||
const inFlight = new Map();
|
||||
// And one try per item per session once it has come back empty: a failed
|
||||
// capture is a resolve plus a download, and a grid that scrolls past the
|
||||
// same dead favorite ten times must not pay for it ten times.
|
||||
const failed = new Set();
|
||||
// One capture at a time. The reader is watching something; a thumbnail has
|
||||
// no business competing with it for bandwidth.
|
||||
let queue = Promise.resolve();
|
||||
|
||||
App.posters.capture = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return Promise.resolve('');
|
||||
const have = App.posters.cached(video);
|
||||
if (have) return Promise.resolve(have);
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
if (failed.has(key)) return Promise.resolve('');
|
||||
// A live stream has no quarter mark, and whatever it is showing now is
|
||||
// not what it will be showing when the card is next looked at.
|
||||
if (video.isLive || (video.meta && video.meta.isLive)) return Promise.resolve('');
|
||||
|
||||
const run = queue.then(() => captureOne(video)).then((src) => {
|
||||
inFlight.delete(key);
|
||||
if (!src) {
|
||||
failed.add(key);
|
||||
return '';
|
||||
}
|
||||
return store(key, src);
|
||||
}, () => {
|
||||
inFlight.delete(key);
|
||||
failed.add(key);
|
||||
return '';
|
||||
});
|
||||
inFlight.set(key, run);
|
||||
queue = run.catch(() => '');
|
||||
return run;
|
||||
};
|
||||
|
||||
// ---- pictures kept before captures were checked ----------------------
|
||||
|
||||
// Decodes a kept picture and says whether there is one in it -- the same
|
||||
// test a fresh capture passes, run on the stored JPEG rather than on a
|
||||
// frame of video.
|
||||
const hasPicture = function(src) {
|
||||
return new Promise((resolve) => {
|
||||
const probe = new Image();
|
||||
probe.onload = function() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = probe.naturalWidth || 1;
|
||||
canvas.height = probe.naturalHeight || 1;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(probe, 0, 0);
|
||||
try {
|
||||
resolve(!isBlank(ctx, canvas.width, canvas.height));
|
||||
} catch (err) {
|
||||
// No way to tell, so leave it where it is.
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
probe.onerror = () => resolve(false);
|
||||
probe.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
// One measurement per item per session, however many cards show it -- and
|
||||
// all of them wait on that one and get the replacement. The same item is on
|
||||
// screen in more than one place (the favorites bar and the grid behind it),
|
||||
// so the first asker getting the picture and the rest being told the kept
|
||||
// one stands would leave the black box up on every card but one.
|
||||
const rechecks = new Map();
|
||||
|
||||
// A picture kept by a version of this that did not check its captures can
|
||||
// be a frame of nothing -- pure black, and kept for the life of the item,
|
||||
// since the only test it had to pass was being a few hundred bytes long.
|
||||
// Each unchecked entry is measured once, after the card has already painted
|
||||
// what there is, and one with nothing in it is dropped and taken again.
|
||||
// Resolves with a replacement picture, or '' when the kept one stands.
|
||||
App.posters.recheck = function(video) {
|
||||
const key = keyOf(video);
|
||||
if (!key) return Promise.resolve('');
|
||||
const running = rechecks.get(key);
|
||||
if (running) return running;
|
||||
const entry = readIndex().find((item) => item.k === key);
|
||||
if (!entry || entry.v) return Promise.resolve('');
|
||||
const src = read(key);
|
||||
if (!src) return Promise.resolve('');
|
||||
const run = hasPicture(src).then((keep) => {
|
||||
if (keep) {
|
||||
// Nothing wrong with it. Said so, so no later session measures
|
||||
// this one again.
|
||||
entry.v = CHECKED;
|
||||
writeIndex();
|
||||
return '';
|
||||
}
|
||||
App.posters.forget(video);
|
||||
return App.posters.capture(video);
|
||||
}, () => '');
|
||||
rechecks.set(key, run);
|
||||
return run;
|
||||
};
|
||||
})();
|
||||
60
frontend/js/privacy.js
Normal file
60
frontend/js/privacy.js
Normal file
@@ -0,0 +1,60 @@
|
||||
window.App = window.App || {};
|
||||
App.privacy = App.privacy || {};
|
||||
|
||||
(function() {
|
||||
// The moment this window stops being the one in front -- another tab,
|
||||
// another window, another app -- the page goes behind a blur. What the site
|
||||
// is showing is nobody else's business: not a glance over the shoulder, not
|
||||
// the app switcher's snapshot, not a screen share of the tab next door.
|
||||
const ON = 'is-on';
|
||||
let veil = null;
|
||||
let veiled = false;
|
||||
|
||||
const element = function() {
|
||||
if (!veil || !veil.isConnected) veil = document.getElementById('privacy-veil');
|
||||
return veil;
|
||||
};
|
||||
|
||||
const setVeiled = function(next) {
|
||||
const el = element();
|
||||
if (!el || next === veiled) return;
|
||||
veiled = next;
|
||||
el.classList.toggle(ON, next);
|
||||
};
|
||||
|
||||
const cover = function() { setVeiled(true); };
|
||||
const uncover = function() { setVeiled(false); };
|
||||
|
||||
function start() {
|
||||
if (!element()) return;
|
||||
|
||||
// Leaving. Any one of these can be the last event before the window is
|
||||
// out of sight, so each covers on its own, and covers at once: a blur
|
||||
// that lands after the app switcher took its picture is no blur at all.
|
||||
window.addEventListener('blur', cover);
|
||||
window.addEventListener('pagehide', cover);
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'hidden') cover();
|
||||
else uncover();
|
||||
});
|
||||
|
||||
// Coming back.
|
||||
window.addEventListener('focus', uncover);
|
||||
window.addEventListener('pageshow', uncover);
|
||||
|
||||
// A backstop, for whatever the browser did or didn't report: someone
|
||||
// using the page is someone looking at it. The first touch lands on the
|
||||
// veil and lifts it instead of reaching the thing underneath, which is
|
||||
// the right way round for a tap that was meant for a page you could see.
|
||||
['pointerdown', 'touchstart', 'keydown', 'wheel'].forEach(function(name) {
|
||||
window.addEventListener(name, uncover, { capture: true, passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
App.privacy = {
|
||||
start: start,
|
||||
cover: cover,
|
||||
uncover: uncover,
|
||||
isVeiled: function() { return veiled; }
|
||||
};
|
||||
})();
|
||||
502
frontend/js/savedSearches.js
Normal file
502
frontend/js/savedSearches.js
Normal file
@@ -0,0 +1,502 @@
|
||||
window.App = window.App || {};
|
||||
App.savedSearches = App.savedSearches || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved searches
|
||||
//
|
||||
// A search here is more than the words in the box: it is the source, the
|
||||
// channel, every filter that channel offers, and the query together. Set all
|
||||
// that up once -- "uncensored JAV, newest first, 1080p and up" -- and this
|
||||
// keeps it, so getting back to it is one tap instead of five.
|
||||
//
|
||||
// Each saved search also remembers what was on screen the last time it was
|
||||
// opened (the ids of the newest videos it showed). Once a day the client asks
|
||||
// each one's first page again and counts how many of those videos it has never
|
||||
// shown the reader, which is the number on the badge. Nothing is counted
|
||||
// server-side and nothing leaves the device but the same listing request the
|
||||
// grid would have made anyway.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function() {
|
||||
const KEY = App.constants.SAVED_SEARCHES_KEY;
|
||||
|
||||
// How many of the newest ids are kept as the "already seen" mark. Several
|
||||
// times the page asked for below: `perPage` is a request, not a promise --
|
||||
// sources answer a page of 12 with 30 whenever that is how their own pages
|
||||
// are cut -- so the mark has to be able to hold a page larger than the one
|
||||
// that was asked for, and then some, for a listing that shifts between
|
||||
// visits to still be measured against something.
|
||||
const SEEN_KEEP = 60;
|
||||
// One page is enough to answer "is there anything new, and roughly how
|
||||
// much": the badge stops counting at 9+ anyway.
|
||||
const PROBE_PER_PAGE = 12;
|
||||
// A group asks each of its channels, so each one asks for less.
|
||||
const GROUP_PROBE_PER_PAGE = 4;
|
||||
// Above this the badge says "9+" rather than a number.
|
||||
const NEW_CAP = 9;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// How often a tab left open gets round to asking again. The check itself
|
||||
// is still once a day per search; this is just how often we look.
|
||||
const HEARTBEAT_MS = 30 * 60 * 1000;
|
||||
// Checks run together, but not all of them: a reader with twenty saved
|
||||
// searches should not open twenty listing requests on startup.
|
||||
const CHECK_CONCURRENCY = 3;
|
||||
const PROBE_TIMEOUT_MS = 12000;
|
||||
|
||||
App.savedSearches.NEW_CAP = NEW_CAP;
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
// ---- store ----------------------------------------------------------
|
||||
|
||||
const clean = function(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
if (!entry.id || !entry.server || !entry.channelId) return null;
|
||||
return {
|
||||
id: String(entry.id),
|
||||
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search',
|
||||
server: String(entry.server),
|
||||
channelId: String(entry.channelId),
|
||||
channelName: typeof entry.channelName === 'string' ? entry.channelName : '',
|
||||
query: typeof entry.query === 'string' ? entry.query : '',
|
||||
options: entry.options && typeof entry.options === 'object' ? entry.options : {},
|
||||
optionLabels: Array.isArray(entry.optionLabels) ? entry.optionLabels.map(String) : [],
|
||||
createdAt: Number(entry.createdAt) || 0,
|
||||
seenIds: Array.isArray(entry.seenIds) ? entry.seenIds.map(String).slice(0, SEEN_KEEP) : [],
|
||||
lastViewedAt: Number(entry.lastViewedAt) || 0,
|
||||
lastCheckedAt: Number(entry.lastCheckedAt) || 0,
|
||||
newCount: Math.max(0, Number(entry.newCount) || 0),
|
||||
unreachable: !!entry.unreachable
|
||||
};
|
||||
};
|
||||
|
||||
App.savedSearches.getAll = function() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(KEY));
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(clean).filter(Boolean);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const setAll = function(entries) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(entries));
|
||||
} catch (err) { /* a full store is not worth failing the click over */ }
|
||||
return entries;
|
||||
};
|
||||
|
||||
App.savedSearches.get = function(id) {
|
||||
return App.savedSearches.getAll().find((entry) => entry.id === id) || null;
|
||||
};
|
||||
|
||||
// Every write goes through here: the list is read again first, so a check
|
||||
// that lands while the reader is renaming or deleting can't write back a
|
||||
// copy of the list from before they did it.
|
||||
const patch = function(id, changes) {
|
||||
const entries = App.savedSearches.getAll();
|
||||
const found = entries.find((entry) => entry.id === id);
|
||||
if (!found) return null;
|
||||
Object.assign(found, changes);
|
||||
setAll(entries);
|
||||
return found;
|
||||
};
|
||||
|
||||
// ---- what is on screen right now -------------------------------------
|
||||
|
||||
const optionLabelsOf = function(options) {
|
||||
const labels = [];
|
||||
Object.values(options || {}).forEach((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
// A multi-select that is fully on says nothing worth printing;
|
||||
// the interesting case is a narrowed one.
|
||||
if (value.length && value.length <= 3) {
|
||||
value.forEach((opt) => labels.push(opt.title || opt.id));
|
||||
} else if (value.length) {
|
||||
labels.push(`${value.length} selected`);
|
||||
}
|
||||
} else if (value && (value.title || value.id)) {
|
||||
labels.push(value.title || value.id);
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
};
|
||||
|
||||
// The search the app is showing, in the shape a saved one is stored in.
|
||||
App.savedSearches.current = function() {
|
||||
const session = App.storage.getSession();
|
||||
if (!session || !session.channel) return null;
|
||||
const box = document.getElementById('search-input');
|
||||
return {
|
||||
server: session.server,
|
||||
channelId: session.channel.id,
|
||||
channelName: session.channel.name || session.channel.id,
|
||||
query: (box ? box.value : '').trim(),
|
||||
options: App.session.serializeOptions(session.options),
|
||||
optionLabels: optionLabelsOf(session.options)
|
||||
};
|
||||
};
|
||||
|
||||
// Two searches are the same search when they would send the same request.
|
||||
App.savedSearches.signature = function(desc) {
|
||||
if (!desc) return '';
|
||||
const options = desc.options || {};
|
||||
const parts = Object.keys(options).sort().map((key) => {
|
||||
const value = options[key];
|
||||
const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value);
|
||||
return `${key}=${flat}`;
|
||||
});
|
||||
return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()]
|
||||
.concat(parts).join('||');
|
||||
};
|
||||
|
||||
// The saved search the app is currently showing, if any -- what makes the
|
||||
// bookmark in the search bar light up.
|
||||
App.savedSearches.findMatching = function() {
|
||||
const desc = App.savedSearches.current();
|
||||
if (!desc) return null;
|
||||
const wanted = App.savedSearches.signature(desc);
|
||||
return App.savedSearches.getAll()
|
||||
.find((entry) => App.savedSearches.signature(entry) === wanted) || null;
|
||||
};
|
||||
|
||||
// What to call a search nobody has named: the words searched for, or the
|
||||
// channel when there were none.
|
||||
const autoName = function(desc) {
|
||||
if (desc.query) return desc.query;
|
||||
const channel = desc.channelName || desc.channelId;
|
||||
const narrowed = desc.optionLabels.slice(0, 2).join(' · ');
|
||||
return narrowed ? `${channel} · ${narrowed}` : channel;
|
||||
};
|
||||
|
||||
// The ids of what the grid is showing, newest first. Saving straight after
|
||||
// a load therefore starts at "nothing new", which is the truth: the reader
|
||||
// is looking at it.
|
||||
const visibleIds = function() {
|
||||
return (App.state.loadedVideos || [])
|
||||
.slice(0, SEEN_KEEP)
|
||||
.map((video) => String(video && (video.id || video.url) || ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
App.savedSearches.saveCurrent = function() {
|
||||
const desc = App.savedSearches.current();
|
||||
if (!desc) return null;
|
||||
const already = App.savedSearches.findMatching();
|
||||
if (already) return already;
|
||||
const entry = clean(Object.assign({}, desc, {
|
||||
id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: autoName(desc),
|
||||
createdAt: now(),
|
||||
seenIds: visibleIds(),
|
||||
lastViewedAt: now(),
|
||||
lastCheckedAt: now(),
|
||||
newCount: 0
|
||||
}));
|
||||
const entries = App.savedSearches.getAll();
|
||||
entries.unshift(entry);
|
||||
setAll(entries);
|
||||
changed();
|
||||
return entry;
|
||||
};
|
||||
|
||||
App.savedSearches.remove = function(id) {
|
||||
setAll(App.savedSearches.getAll().filter((entry) => entry.id !== id));
|
||||
changed();
|
||||
};
|
||||
|
||||
App.savedSearches.rename = function(id, name) {
|
||||
const next = (name || '').trim();
|
||||
if (!next) return null;
|
||||
const entry = patch(id, { name: next.slice(0, 80) });
|
||||
changed();
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Opened, so whatever it shows now counts as seen and the badge goes out.
|
||||
App.savedSearches.markViewed = function(id) {
|
||||
const seen = visibleIds();
|
||||
const entry = patch(id, Object.assign({
|
||||
lastViewedAt: now(),
|
||||
lastCheckedAt: now(),
|
||||
newCount: 0,
|
||||
unreachable: false
|
||||
}, seen.length ? { seenIds: seen } : {}));
|
||||
changed();
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Being on a saved search is viewing it, however the reader got there --
|
||||
// by tapping it here, or by setting the same channel and filters by hand.
|
||||
App.savedSearches.syncViewed = function() {
|
||||
const match = App.savedSearches.findMatching();
|
||||
if (match) App.savedSearches.markViewed(match.id);
|
||||
return match;
|
||||
};
|
||||
|
||||
App.savedSearches.totalNew = function() {
|
||||
return App.savedSearches.getAll()
|
||||
.reduce((sum, entry) => sum + (entry.newCount || 0), 0);
|
||||
};
|
||||
|
||||
// What a count looks like on a badge: nothing, a number, or "9+".
|
||||
App.savedSearches.badgeLabel = function(count) {
|
||||
if (!count) return '';
|
||||
return count > NEW_CAP ? `${NEW_CAP}+` : String(count);
|
||||
};
|
||||
|
||||
const changed = function() {
|
||||
if (App.ui && App.ui.savedSearches && typeof App.ui.savedSearches.sync === 'function') {
|
||||
App.ui.savedSearches.sync();
|
||||
}
|
||||
};
|
||||
|
||||
// ---- the daily check -------------------------------------------------
|
||||
|
||||
// The ids on the first page of a saved search, as the server orders them.
|
||||
// Not the grid's request: the grid's paging state belongs to what the
|
||||
// reader is looking at, and a check must not disturb it.
|
||||
const probeIds = async function(entry, signal) {
|
||||
const serverEntry = App.storage.getServerEntries()
|
||||
.find((candidate) => candidate.url === entry.server);
|
||||
const serverData = serverEntry ? serverEntry.data : null;
|
||||
const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null;
|
||||
if (!channel) throw new Error('channel is no longer listed');
|
||||
|
||||
const ask = async function(channelId, perPage, extra) {
|
||||
const body = Object.assign({
|
||||
channel: channelId,
|
||||
query: entry.query || '',
|
||||
page: 1,
|
||||
perPage: perPage,
|
||||
server: entry.server
|
||||
}, extra || {});
|
||||
const response = await fetch('/api/videos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`listing request failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const items = data && Array.isArray(data.items) ? data.items : [];
|
||||
return items.map((item) => String(item && (item.id || item.url) || '')).filter(Boolean);
|
||||
};
|
||||
|
||||
if (channel.isGroup) {
|
||||
const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
|
||||
const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds)
|
||||
.filter((id) => channel.channelIds.includes(id));
|
||||
if (!enabled.length) return [];
|
||||
const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE)));
|
||||
// Interleaved, so "the newest" means the same thing it does in the
|
||||
// grid: one from each channel in turn.
|
||||
const merged = [];
|
||||
const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||
for (let i = 0; i < longest; i++) {
|
||||
perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
Object.entries(entry.options || {}).forEach(([key, value]) => {
|
||||
extra[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||
});
|
||||
return ask(entry.channelId, PROBE_PER_PAGE, extra);
|
||||
};
|
||||
|
||||
// How many of the videos the source lists now are ones the reader has not
|
||||
// been shown -- counted only as far down the page as the mark reaches.
|
||||
//
|
||||
// `perPage` is a request, not a promise: a source whose own pages are 30
|
||||
// long answers a page of 12 with 30. The reader was shown as much of that
|
||||
// as the grid asked for, so an id further down than the mark goes has not
|
||||
// been seen and never was new either -- it was on the same page the whole
|
||||
// time. Counting those would put a number on the badge that no amount of
|
||||
// looking takes off again.
|
||||
const countNew = function(ids, seen) {
|
||||
return ids.slice(0, seen.size).filter((id) => !seen.has(id)).length;
|
||||
};
|
||||
|
||||
const askSource = async function(entry) {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
return await probeIds(entry, controller.signal);
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const checkOne = async function(entry) {
|
||||
try {
|
||||
const ids = await askSource(entry);
|
||||
const seen = new Set(entry.seenIds || []);
|
||||
// A search nobody has ever opened has nothing to compare against,
|
||||
// so the whole first page is its mark rather than its news.
|
||||
if (!seen.size) {
|
||||
patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false });
|
||||
return;
|
||||
}
|
||||
patch(entry.id, { lastCheckedAt: now(), newCount: countNew(ids, seen), unreachable: false });
|
||||
} catch (err) {
|
||||
// A source that is down must not look like a search with nothing
|
||||
// new in it, and must not reset the clock either: it is tried
|
||||
// again on the next look.
|
||||
patch(entry.id, { unreachable: true });
|
||||
}
|
||||
};
|
||||
|
||||
let running = null;
|
||||
|
||||
// Checks every saved search that is due (or all of them, with `force`).
|
||||
// Resolves when they have all been written back; safe to call at any time,
|
||||
// and calling it while a run is in flight joins that run.
|
||||
App.savedSearches.check = function(opts) {
|
||||
const force = !!(opts && opts.force);
|
||||
if (running) return running;
|
||||
const due = App.savedSearches.getAll().filter((entry) =>
|
||||
force || !entry.lastCheckedAt || (now() - entry.lastCheckedAt) >= DAY_MS);
|
||||
if (!due.length) return Promise.resolve(0);
|
||||
|
||||
const queue = due.slice();
|
||||
const worker = async function() {
|
||||
while (queue.length) {
|
||||
await checkOne(queue.shift());
|
||||
changed();
|
||||
}
|
||||
};
|
||||
running = Promise.all(
|
||||
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
|
||||
).then(() => {
|
||||
running = null;
|
||||
changed();
|
||||
return due.length;
|
||||
}).catch(() => {
|
||||
running = null;
|
||||
changed();
|
||||
return 0;
|
||||
});
|
||||
return running;
|
||||
};
|
||||
|
||||
App.savedSearches.isChecking = function() { return !!running; };
|
||||
|
||||
let marking = null;
|
||||
|
||||
// The whole list, read. For a reader who sees the badge, decides none of
|
||||
// it is for them, and wants the slate clean rather than nine rows to open
|
||||
// one after another.
|
||||
//
|
||||
// The mark is moved on to what each source lists now rather than the count
|
||||
// merely being zeroed: a count that is only zeroed comes back on the next
|
||||
// check, because the videos it counted are still ones nobody was shown.
|
||||
// That is one listing request per saved search, the same one the daily
|
||||
// check makes.
|
||||
App.savedSearches.markAllRead = function() {
|
||||
if (marking) return marking;
|
||||
const entries = App.savedSearches.getAll();
|
||||
if (!entries.length) return Promise.resolve(0);
|
||||
|
||||
const queue = entries.slice();
|
||||
const worker = async function() {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift();
|
||||
try {
|
||||
const ids = await askSource(entry);
|
||||
patch(entry.id, {
|
||||
seenIds: ids.slice(0, SEEN_KEEP),
|
||||
lastViewedAt: now(),
|
||||
lastCheckedAt: now(),
|
||||
newCount: 0,
|
||||
unreachable: false
|
||||
});
|
||||
} catch (err) {
|
||||
// The source did not answer, so the mark cannot be moved
|
||||
// forward. The count still goes out -- the reader said they
|
||||
// are done with it -- and the row says it could not be
|
||||
// reached, so the number coming back later is explained.
|
||||
patch(entry.id, { newCount: 0, lastViewedAt: now(), unreachable: true });
|
||||
}
|
||||
changed();
|
||||
}
|
||||
};
|
||||
marking = Promise.all(
|
||||
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
|
||||
).then(() => {
|
||||
marking = null;
|
||||
changed();
|
||||
return entries.length;
|
||||
}).catch(() => {
|
||||
marking = null;
|
||||
changed();
|
||||
return 0;
|
||||
});
|
||||
changed();
|
||||
return marking;
|
||||
};
|
||||
|
||||
App.savedSearches.isMarkingAllRead = function() { return !!marking; };
|
||||
|
||||
// ---- opening one -----------------------------------------------------
|
||||
|
||||
// Puts the whole saved search back: source, channel, filters, query. The
|
||||
// grid is reloaded here rather than left to the caller, because "applied"
|
||||
// means the reader is looking at it -- which is also when it stops being
|
||||
// new (see markViewed).
|
||||
App.savedSearches.apply = async function(id) {
|
||||
const entry = App.savedSearches.get(id);
|
||||
if (!entry) return false;
|
||||
|
||||
const serverEntry = App.storage.getServerEntries()
|
||||
.find((candidate) => candidate.url === entry.server);
|
||||
if (!serverEntry || !serverEntry.data) {
|
||||
App.ui.showError(`${entry.name}: the source ${entry.server} isn't set up on this device any more.`);
|
||||
return false;
|
||||
}
|
||||
const channel = App.session.resolveChannelById(serverEntry.data, entry.channelId);
|
||||
if (!channel) {
|
||||
App.ui.showError(`${entry.name}: ${entry.server} no longer lists that channel.`);
|
||||
patch(entry.id, { unreachable: true });
|
||||
changed();
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = {
|
||||
server: entry.server,
|
||||
channel: channel,
|
||||
options: App.session.hydrateOptions(channel, entry.options)
|
||||
};
|
||||
App.storage.setSession(session);
|
||||
App.session.savePreference(session);
|
||||
|
||||
App.videos.setSearchBox(entry.query || '');
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.close({ silent: true });
|
||||
}
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetGrid();
|
||||
await App.videos.loadVideos();
|
||||
App.savedSearches.markViewed(entry.id);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------
|
||||
|
||||
let started = false;
|
||||
|
||||
App.savedSearches.start = function() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
changed();
|
||||
App.savedSearches.check();
|
||||
// A tab left open for days still gets round to it.
|
||||
window.setInterval(() => App.savedSearches.check(), HEARTBEAT_MS);
|
||||
// And a phone that had the app in the background for a day checks as
|
||||
// soon as it is looked at again, rather than at the next reload.
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'visible') App.savedSearches.check();
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -24,7 +24,12 @@ App.constants = {
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||
SEARCH_HISTORY_KEY: 'searchHistory',
|
||||
SAVED_SEARCHES_KEY: 'savedSearches',
|
||||
// Thumbnails captured out of a video whose listed picture is gone:
|
||||
// the index, plus one entry per picture under `<key>:<hash>`.
|
||||
POSTER_CACHE_KEY: 'posterCache'
|
||||
};
|
||||
|
||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||
|
||||
@@ -48,6 +48,73 @@ App.session = App.session || {};
|
||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Searches
|
||||
//
|
||||
// What was searched for before, newest first. The grid searches as you
|
||||
// type, so the box never sees a "submit" to record: every pause in the
|
||||
// typing is a search of its own and storing each one would fill the list
|
||||
// with the way to a query rather than the query. So a new entry swallows
|
||||
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
|
||||
// -- and going back to something shorter than what is already stored adds
|
||||
// nothing, since that is a step back through the same typing.
|
||||
// ---------------------------------------------------------------------
|
||||
const SEARCH_HISTORY_MAX = 50;
|
||||
|
||||
App.storage.getSearches = function() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
|
||||
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
App.storage.rememberSearch = function(query) {
|
||||
const next = (query || '').trim();
|
||||
if (!next) return App.storage.getSearches();
|
||||
const lower = next.toLowerCase();
|
||||
const stored = App.storage.getSearches();
|
||||
// Already on the way to something longer: nothing new to record.
|
||||
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
|
||||
return stored;
|
||||
}
|
||||
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
|
||||
kept.unshift(next);
|
||||
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
|
||||
try {
|
||||
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
|
||||
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
|
||||
return capped;
|
||||
};
|
||||
|
||||
App.storage.forgetSearches = function() {
|
||||
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
|
||||
};
|
||||
|
||||
// The past searches worth offering for what is being typed, best first.
|
||||
// Best means where the typing lands in them -- at the front, at the start
|
||||
// of a word, anywhere at all -- and then which was searched for most
|
||||
// recently. What is already typed in full is not a suggestion.
|
||||
App.storage.suggestSearches = function(query, limit) {
|
||||
const want = Math.max(1, limit || 5);
|
||||
const typed = (query || '').trim().toLowerCase();
|
||||
const scored = [];
|
||||
App.storage.getSearches().forEach((entry, age) => {
|
||||
const candidate = entry.toLowerCase();
|
||||
if (candidate === typed) return;
|
||||
let rank = 0;
|
||||
if (typed) {
|
||||
const at = candidate.indexOf(typed);
|
||||
if (at < 0) return;
|
||||
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
|
||||
}
|
||||
scored.push({ entry: entry, rank: rank, age: age });
|
||||
});
|
||||
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
|
||||
return scored.slice(0, want).map((s) => s.entry);
|
||||
};
|
||||
|
||||
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||
App.storage.getDensity = function() {
|
||||
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||
|
||||
1206
frontend/js/ui.js
1206
frontend/js/ui.js
File diff suppressed because it is too large
Load Diff
@@ -54,9 +54,12 @@ App.version = App.version || {};
|
||||
// a reload because it is restored from localStorage on boot.
|
||||
function isSafeToReload() {
|
||||
if (App.state && App.state.feedOpen) return false;
|
||||
const player = document.getElementById('custom-player');
|
||||
if (player && player.classList.contains('open')) {
|
||||
const video = player.querySelector('.cp-video');
|
||||
// A player still loading out of sight counts as in use: reloading
|
||||
// would throw away the video the viewer just asked for.
|
||||
if (App.player && typeof App.player.isActive === 'function' && App.player.isActive()) {
|
||||
const player = document.getElementById('custom-player');
|
||||
const video = player && player.querySelector('.cp-video');
|
||||
if (!player.classList.contains('open')) return false;
|
||||
if (video && !video.paused && !video.ended) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
tests/fixtures/clip.mp4
vendored
Normal file
BIN
tests/fixtures/clip.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/dark.mp4
vendored
Normal file
BIN
tests/fixtures/dark.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/wide.mp4
vendored
Normal file
BIN
tests/fixtures/wide.mp4
vendored
Normal file
Binary file not shown.
206
tests/smoke_channels.py
Normal file
206
tests/smoke_channels.py
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Channel picker smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_channels.py
|
||||
|
||||
The picker replaced a <select>, so what's checked here is what the <select>
|
||||
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
|
||||
let it be searched -- plus the two things it could: switching the channel, and
|
||||
feeding the command palette its list.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottub.spacemoehre.de"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
ROWS = """() => {
|
||||
const list = document.getElementById('channel-picker-list');
|
||||
const rows = Array.from(list.querySelectorAll('.channel-row'));
|
||||
return {
|
||||
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
|
||||
count: rows.length,
|
||||
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
|
||||
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
|
||||
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
|
||||
iconsLoaded: rows.filter((r) => {
|
||||
const img = r.querySelector('img.channel-favicon');
|
||||
return img && img.naturalWidth > 0;
|
||||
}).length,
|
||||
current: rows.filter((r) => r.classList.contains('is-current'))
|
||||
.map((r) => r.dataset.channelId),
|
||||
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
|
||||
emptyHidden: document.getElementById('channel-picker-empty').hidden,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
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 boot(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
|
||||
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",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
boot(page)
|
||||
|
||||
print("\nthe menu says which channel is being read")
|
||||
page.evaluate("() => App.ui.toggleDrawer('menu')")
|
||||
page.wait_for_timeout(2500)
|
||||
trigger = page.evaluate("""() => ({
|
||||
name: document.getElementById('channel-trigger-name').textContent,
|
||||
note: document.getElementById('channel-trigger-note').textContent,
|
||||
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
|
||||
})""")
|
||||
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
|
||||
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
|
||||
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
|
||||
|
||||
print("\nopening it lists the channels with what the server knows")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(3000)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
|
||||
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
|
||||
str(rows["sections"][:4]))
|
||||
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
|
||||
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
|
||||
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
|
||||
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
|
||||
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
|
||||
|
||||
print("\nsearching narrows it")
|
||||
page.fill("#channel-search", "hentai")
|
||||
page.wait_for_timeout(600)
|
||||
found = page.evaluate(ROWS)
|
||||
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
|
||||
# A row can match on its own text or on the group it sits under -- the
|
||||
# heading is as much a fact about the channel as its description.
|
||||
c.ok("every row left standing matches what was typed",
|
||||
page.evaluate("""() => {
|
||||
let heading = '';
|
||||
return Array.from(document.getElementById('channel-picker-list').children)
|
||||
.every((node) => {
|
||||
if (node.classList.contains('channel-section')) {
|
||||
heading = node.textContent.toLowerCase();
|
||||
return true;
|
||||
}
|
||||
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
|
||||
return text.toLowerCase().includes('hentai');
|
||||
});
|
||||
}"""))
|
||||
# Description and tags are searched too, not just the name.
|
||||
page.fill("#channel-search", "leaks")
|
||||
page.wait_for_timeout(600)
|
||||
by_tag = page.evaluate(ROWS)
|
||||
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
|
||||
str(by_tag["count"]))
|
||||
|
||||
page.fill("#channel-search", "zzzznothing")
|
||||
page.wait_for_timeout(600)
|
||||
nothing = page.evaluate(ROWS)
|
||||
c.ok("a search with no hits says so",
|
||||
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
|
||||
|
||||
print("\npicking one switches the feed")
|
||||
page.fill("#channel-search", "eporner")
|
||||
page.wait_for_timeout(600)
|
||||
page.click(".channel-row")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("the picker closes",
|
||||
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
|
||||
session = page.evaluate("() => App.storage.getSession().channel.id")
|
||||
c.ok("the session moved to it", session == "eporner", session)
|
||||
c.ok("the trigger followed",
|
||||
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
c.ok("and the grid reloaded",
|
||||
page.evaluate("() => App.state.loadedVideos.length > 0"))
|
||||
|
||||
print("\nthe keyboard works too")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(1200)
|
||||
page.fill("#channel-search", "beeg")
|
||||
page.wait_for_timeout(500)
|
||||
page.press("#channel-search", "ArrowDown")
|
||||
page.press("#channel-search", "ArrowUp")
|
||||
page.press("#channel-search", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("Enter picks the highlighted row",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
|
||||
page.evaluate("() => App.storage.getSession().channel.id"))
|
||||
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(800)
|
||||
page.press("#channel-search", "Escape")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("Escape closes it without changing anything",
|
||||
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
|
||||
&& App.storage.getSession().channel.id === 'beeg'"""))
|
||||
|
||||
print("\nthe command palette still offers the channels")
|
||||
entries = page.evaluate("() => App.ui.channels.entries().length")
|
||||
c.ok("the picker hands out its list", entries > 50, str(entries))
|
||||
page.evaluate("() => App.enhance.openPalette()")
|
||||
page.wait_for_timeout(400)
|
||||
page.fill("#cmdk-input", "Redtube")
|
||||
page.wait_for_timeout(400)
|
||||
page.press("#cmdk-input", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("and choosing one from there switches the channel",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
|
||||
page.evaluate("() => App.storage.getSession().channel.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())
|
||||
175
tests/smoke_docpip.py
Normal file
175
tests/smoke_docpip.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Document picture-in-picture: the reel, in a window, scrolling.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_docpip.py
|
||||
|
||||
A video picture-in-picture window renders one <video>'s frames and cannot
|
||||
scroll. Document picture-in-picture opens a real document instead, so the feed
|
||||
is *moved* into it -- the same elements, another window. That is what these
|
||||
checks are about: the move must be a move (no rebuild, playback intact), the
|
||||
window must scroll the reel for real, and closing it must put the feed back
|
||||
where it came from.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# The feed can be read from either document. Only App.state lives in the page --
|
||||
# the window's document has no scripts of its own; it holds the moved elements,
|
||||
# whose handlers are still the page's closures. That is the whole design, so the
|
||||
# probe takes the document to look in and always runs in the page.
|
||||
PROBE = """(where) => {
|
||||
const doc = where === 'pip'
|
||||
? (documentPictureInPicture.window && documentPictureInPicture.window.document)
|
||||
: document;
|
||||
if (!doc) return null;
|
||||
const root = doc.getElementById('feed-view');
|
||||
const active = doc.querySelector('.feed-slide.is-active');
|
||||
const scroller = doc.getElementById('feed-scroll');
|
||||
return {
|
||||
rooted_here: !!root,
|
||||
slides: doc.querySelectorAll('.feed-slide').length,
|
||||
step: App.state.feedActiveIndex,
|
||||
video: App.state.feedActiveVideoId,
|
||||
playing: active ? Array.from(active.querySelectorAll('.feed-video'))
|
||||
.map(v => !v.paused && v.readyState >= 2) : null,
|
||||
scrollable: scroller ? scroller.scrollHeight > scroller.clientHeight + 1 : false,
|
||||
unique: (() => {
|
||||
const ids = Array.from(doc.querySelectorAll('.feed-pane')).map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size;
|
||||
})(),
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||
page = context.new_page()
|
||||
open_reels(page)
|
||||
|
||||
c.ok("this browser offers document picture-in-picture",
|
||||
page.evaluate("() => App.feed.docPipSupported()"))
|
||||
|
||||
before = page.evaluate(PROBE, 'page')
|
||||
c.ok("the feed starts in the page", before["rooted_here"])
|
||||
c.ok("and is scrollable there", before["scrollable"])
|
||||
|
||||
print("\nthe picture-in-picture button opens a window")
|
||||
# A click, not an evaluate: requestWindow needs a user gesture, which is
|
||||
# exactly the reason the tab-switch path cannot use this API.
|
||||
with context.expect_page(timeout=15000) as caught:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip = caught.value
|
||||
pip.wait_for_timeout(3000)
|
||||
|
||||
c.ok("the feed reports a window open", page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed left the page", not page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page underneath is usable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
moved = page.evaluate(PROBE, 'pip')
|
||||
c.ok("the feed is in the window", moved["rooted_here"], str(moved))
|
||||
c.ok("its slides came with it", moved["slides"] > 0, str(moved["slides"]))
|
||||
# The point of moving rather than rebuilding: the <video> elements are
|
||||
# the same ones, so nothing reloads and nothing stops.
|
||||
c.ok("playback survived the move", moved["playing"] and all(moved["playing"]),
|
||||
str(moved["playing"]))
|
||||
c.ok("it landed on the same video", moved["video"] == before["video"],
|
||||
f"{before['video']} -> {moved['video']}")
|
||||
|
||||
print("\nthe window scrolls the reel")
|
||||
c.ok("the window's feed is scrollable", moved["scrollable"], str(moved))
|
||||
pip.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
pip.wait_for_timeout(3500)
|
||||
scrolled = page.evaluate(PROBE, 'pip')
|
||||
c.ok("scrolling moved to the next step", scrolled["step"] == moved["step"] + 1,
|
||||
f"{moved['step']} -> {scrolled['step']}")
|
||||
c.ok("and to another video", scrolled["video"] != moved["video"],
|
||||
f"{moved['video']} -> {scrolled['video']}")
|
||||
c.ok("the new step is playing", scrolled["playing"] and all(scrolled["playing"]),
|
||||
str(scrolled["playing"]))
|
||||
|
||||
print("\nclosing the window brings the feed home")
|
||||
pip.close()
|
||||
page.wait_for_timeout(3500)
|
||||
back = page.evaluate(PROBE, 'page')
|
||||
c.ok("no window is open", not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed is in the page again", back["rooted_here"], str(back))
|
||||
c.ok("reels is still open", page.evaluate("() => App.feed.isOpen()"))
|
||||
c.ok("the page is back in reels mode", page.evaluate(
|
||||
"() => document.body.classList.contains('feed-mode-open')"))
|
||||
# The window was scrolled while it was out; coming back must not undo it.
|
||||
c.ok("it kept where the window left off", back["video"] == scrolled["video"],
|
||||
f"{scrolled['video']} -> {back['video']}")
|
||||
c.ok("the feed still scrolls here", back["scrollable"], str(back))
|
||||
c.ok("no video was duplicated by the round trip", back["unique"])
|
||||
|
||||
print("\nleaving reels while the window is open")
|
||||
with context.expect_page(timeout=15000) as caught2:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip2 = caught2.value
|
||||
pip2.wait_for_timeout(2500)
|
||||
page.evaluate("() => App.feed.close()")
|
||||
page.wait_for_timeout(2000)
|
||||
c.ok("closing reels closes the window", pip2.is_closed() or
|
||||
not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed came back before it was torn down", page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page is scrollable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
248
tests/smoke_feed_swipe.py
Normal file
248
tests/smoke_feed_swipe.py
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A swipe counts even when the viewport just moved.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_feed_swipe.py
|
||||
|
||||
Both the status and the listing are fixtures, so the reel is long enough to
|
||||
swipe through and the test never waits on a real source.
|
||||
|
||||
A viewport change opens a realign window in which the feed holds the reader's
|
||||
video in place, because the scroll-snap container re-anchors on positions
|
||||
measured against the old height and must not be allowed to flip the video (see
|
||||
smoke_rotate). The window used to revert *every* scroll it saw, and on a phone
|
||||
that is the worst possible moment to do it: the chrome sliding out of the way
|
||||
is caused by the swipe, so the window was open exactly when the reader was
|
||||
swiping. The reel moved under the finger -- far enough to show the next video
|
||||
coming in at the bottom -- and was then pulled back to where it started.
|
||||
|
||||
So the two halves have to be told apart, and the test asks for both: a scroll
|
||||
with real input behind it is the reader's and is kept, a bare re-anchor is not
|
||||
and is still undone.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
PORTRAIT = {"width": 430, "height": 930}
|
||||
# Chrome sliding away: height only, so this is not a turn -- it is the resize
|
||||
# that used to eat the swipe that caused it.
|
||||
SHORTER = {"width": 430, "height": 860}
|
||||
# Near enough to square that a little height crosses it. Reading the `w > h`
|
||||
# flip on its own called that a rotation, pinned the slides and turned
|
||||
# snapping off for about a second.
|
||||
SQUARE = {"width": 800, "height": 810}
|
||||
SQUARE_SHORT = {"width": 800, "height": 790}
|
||||
|
||||
STATUS = {
|
||||
"online": True,
|
||||
"channels": [{
|
||||
"id": CHANNEL,
|
||||
"name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.",
|
||||
"options": [],
|
||||
}],
|
||||
}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
WHERE = """() => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const root = document.getElementById('feed-view');
|
||||
const active = document.querySelector('.feed-slide.is-active');
|
||||
const h = scroller.clientHeight;
|
||||
return {
|
||||
index: App.state.feedActiveIndex,
|
||||
videoId: App.state.feedActiveVideoId,
|
||||
scrollTop: Math.round(scroller.scrollTop),
|
||||
expected: App.state.feedActiveIndex * h,
|
||||
height: h,
|
||||
activeTop: active ? Math.round(active.getBoundingClientRect().top) : None_,
|
||||
turning: root.classList.contains('is-turning'),
|
||||
snap: getComputedStyle(scroller).scrollSnapType,
|
||||
offHeight: Array.from(document.querySelectorAll('.feed-slide'))
|
||||
.map((s) => s.getBoundingClientRect().height)
|
||||
.filter((sh) => Math.abs(sh - h) > 1),
|
||||
};
|
||||
}""".replace("None_", "null")
|
||||
|
||||
GO = """(step) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
scroller.scrollTop = step * scroller.clientHeight;
|
||||
return App.state.feedActiveIndex;
|
||||
}"""
|
||||
|
||||
# The container's own re-anchor: a scroll position and nothing behind it.
|
||||
RESNAP = """(top) => {
|
||||
document.getElementById('feed-scroll').scrollTop = top;
|
||||
}"""
|
||||
|
||||
# A swipe, and the chrome reporting itself before the feed has caught up.
|
||||
# setActive runs from onScroll's next frame, so doing both in one block --
|
||||
# no frame boundary in between -- leaves feedActiveVideoId still naming the
|
||||
# video being left, which is the state a phone resize routinely lands in.
|
||||
# onResize then re-anchors synchronously, with no scroll event for the
|
||||
# input guard in onScroll to see.
|
||||
SWIPE_THEN_RESIZE = """() => {
|
||||
const s = document.getElementById('feed-scroll');
|
||||
const before = App.state.feedActiveIndex;
|
||||
s.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }));
|
||||
s.scrollTop = s.scrollTop + s.clientHeight;
|
||||
const moved = s.scrollTop;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
return { before: before, moved: moved, after: s.scrollTop, h: s.clientHeight };
|
||||
}"""
|
||||
|
||||
|
||||
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():
|
||||
return [{"id": f"vid-{i}", "title": f"clip {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
|
||||
|
||||
|
||||
def swipe_to(page, step):
|
||||
for n in range(1, step + 1):
|
||||
page.evaluate(GO, n)
|
||||
page.wait_for_timeout(250)
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
|
||||
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=PORTRAIT)
|
||||
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}})))
|
||||
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
|
||||
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
|
||||
|
||||
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)
|
||||
page.wait_for_timeout(500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
print("\nseven videos into the reel")
|
||||
swipe_to(page, 7)
|
||||
start = page.evaluate(WHERE)
|
||||
c.ok("that is where the reel is", start["index"] == 7, str(start))
|
||||
|
||||
print("\na swipe while the chrome is still sliding away")
|
||||
page.mouse.move(215, 500)
|
||||
page.set_viewport_size(SHORTER)
|
||||
page.wait_for_timeout(50) # well inside the realign window
|
||||
page.mouse.wheel(0, SHORTER["height"])
|
||||
page.wait_for_timeout(2200)
|
||||
swiped = page.evaluate(WHERE)
|
||||
c.ok("the swipe moved the reel on",
|
||||
swiped["index"] == start["index"] + 1,
|
||||
f"step {start['index']} -> {swiped['index']}")
|
||||
c.ok("onto the next video",
|
||||
swiped["videoId"] != start["videoId"],
|
||||
f"{start['videoId']} -> {swiped['videoId']}")
|
||||
c.ok("and it is sitting on that step, not between two",
|
||||
abs(swiped["scrollTop"] - swiped["expected"]) <= 1, str(swiped))
|
||||
c.ok("so no second video shows at the bottom of the screen",
|
||||
swiped["activeTop"] == 0, str(swiped["activeTop"]))
|
||||
|
||||
print("\na bare re-anchor in the same window is still undone")
|
||||
page.wait_for_timeout(900) # let the input grace lapse
|
||||
held = page.evaluate(WHERE)
|
||||
page.set_viewport_size(PORTRAIT)
|
||||
page.wait_for_timeout(50)
|
||||
# A position measured against the height from before the resize, which
|
||||
# is what the container itself hands back mid-rotation.
|
||||
page.evaluate(RESNAP, held["index"] * held["height"])
|
||||
page.wait_for_timeout(1800)
|
||||
resnapped = page.evaluate(WHERE)
|
||||
c.ok("the same video is still playing",
|
||||
resnapped["videoId"] == held["videoId"],
|
||||
f"{held['videoId']} -> {resnapped['videoId']}")
|
||||
c.ok("on the same step",
|
||||
resnapped["index"] == held["index"],
|
||||
f"{held['index']} -> {resnapped['index']}")
|
||||
c.ok("re-anchored onto its own snap point",
|
||||
abs(resnapped["scrollTop"] - resnapped["expected"]) <= 1, str(resnapped))
|
||||
|
||||
print("\nthe chrome reporting itself before the feed has caught up")
|
||||
page.wait_for_timeout(900)
|
||||
pre = page.evaluate(WHERE)
|
||||
got = page.evaluate(SWIPE_THEN_RESIZE)
|
||||
page.wait_for_timeout(2200)
|
||||
kept = page.evaluate(WHERE)
|
||||
print(f" (swiped to {got['moved']}px; the resize left it at {got['after']}px)")
|
||||
c.ok("the resize did not haul the reel back to the video being left",
|
||||
got["after"] >= got["moved"] - 1,
|
||||
f"{got['moved']} -> {got['after']}")
|
||||
c.ok("the swipe still landed on the next step",
|
||||
kept["index"] == pre["index"] + 1,
|
||||
f"step {pre['index']} -> {kept['index']}")
|
||||
c.ok("and on that step's snap point",
|
||||
abs(kept["scrollTop"] - kept["expected"]) <= 1, str(kept))
|
||||
|
||||
print("\na little height on a near-square viewport is not a rotation")
|
||||
page.set_viewport_size(SQUARE)
|
||||
page.wait_for_timeout(1800)
|
||||
page.set_viewport_size(SQUARE_SHORT)
|
||||
page.wait_for_timeout(120) # mid-window, when the pin would be on
|
||||
pinned = page.evaluate(WHERE)
|
||||
c.ok("the slides are not pinned for it", not pinned["turning"], str(pinned["turning"]))
|
||||
c.ok("so snapping stays on", pinned["snap"] != "none", str(pinned["snap"]))
|
||||
page.wait_for_timeout(1800)
|
||||
settled = page.evaluate(WHERE)
|
||||
c.ok("every slide is exactly a screenful",
|
||||
settled["offHeight"] == [], str(settled["offHeight"]))
|
||||
c.ok("and the reel rests on its snap point",
|
||||
abs(settled["scrollTop"] - settled["expected"]) <= 1, str(settled))
|
||||
c.ok("still on the same video",
|
||||
settled["videoId"] == kept["videoId"],
|
||||
f"{kept['videoId']} -> {settled['videoId']}")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
253
tests/smoke_grid.py
Normal file
253
tests/smoke_grid.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grid smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_grid.py
|
||||
|
||||
The load-bearing check here is `content bleed`: every mounted card must render
|
||||
the video its own data-video-id names. Nothing enforced that before cards were
|
||||
recycled, because a card was thrown away the moment it left the window; once
|
||||
cards are reused, a release path that forgets to clear something shows one
|
||||
video's title, thumbnail or heart on another video's card.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
# Everything a mounted card renders, next to what its own id says it should.
|
||||
INSPECT = """() => {
|
||||
const byId = new Map();
|
||||
(App.state.loadedVideos || []).forEach((v) => byId.set(String(v.id), v));
|
||||
return Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||
const id = card.dataset.videoId;
|
||||
const v = byId.get(String(id)) || null;
|
||||
const img = card.querySelector('img');
|
||||
const dur = card.querySelector('.video-duration');
|
||||
const up = card.querySelector('.video-uploader');
|
||||
const fav = card.querySelector('.favorite-btn');
|
||||
return {
|
||||
id: id,
|
||||
known: !!v,
|
||||
title_shown: (card.querySelector('.video-title-text') || {}).textContent || '',
|
||||
title_expected: v ? (v.title || '') : null,
|
||||
// A thumbnail is served either straight from the provider or via
|
||||
// /api/image?url=<encoded>; compare on the provider URL either way.
|
||||
src_shown: (() => {
|
||||
const raw = img ? (img.getAttribute('src') || '') : '';
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const u = new URL(raw, location.href);
|
||||
return u.pathname === '/api/image'
|
||||
? (u.searchParams.get('url') || raw) : raw;
|
||||
} catch (e) { return raw; }
|
||||
})(),
|
||||
thumb_expected: v ? (v.thumb || '') : null,
|
||||
duration_shown: dur && !dur.hidden ? dur.textContent : '',
|
||||
duration_expected: v ? (App.videos.formatDuration(v.duration) || '') : null,
|
||||
uploader_shown: up && !up.hidden ? (up.dataset.uploader || up.textContent || '') : '',
|
||||
uploader_expected: v ? (v.uploader || '') : null,
|
||||
heart_shown: fav ? fav.classList.contains('is-favorite') : null,
|
||||
heart_expected: v ? App.favorites.has(v) : null,
|
||||
stale_loading: card.classList.contains('is-loading'),
|
||||
stale_pop: fav ? fav.classList.contains('just-favorited') : false,
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
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 boot(page):
|
||||
"""Seed a known server/channel, then wait for the grid to fill.
|
||||
|
||||
Startup renders from the status cached in localStorage and refreshes it in
|
||||
the background, so the first visit has to wait for that round trip before
|
||||
any video is loaded.
|
||||
"""
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
# One reload, in case the status refresh or the listing request failed.
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def grow(page, want=80, tries=14):
|
||||
"""Load enough videos that the grid is taller than the mount window.
|
||||
|
||||
The virtualizer keeps everything within 1.2 viewports of the screen mounted,
|
||||
so a short list never unmounts anything and never exercises recycling.
|
||||
"""
|
||||
for _ in range(tries):
|
||||
if page.evaluate("() => App.state.loadedVideos.length") >= want:
|
||||
break
|
||||
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
|
||||
page.wait_for_timeout(2000)
|
||||
return page.evaluate("() => App.state.loadedVideos.length")
|
||||
|
||||
|
||||
def scroll_around(page, downs=10):
|
||||
"""Churn the mount/unmount path: far down, then back to the top."""
|
||||
for _ in range(downs):
|
||||
page.evaluate("() => window.scrollBy(0, window.innerHeight * 1.5)")
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(1200)
|
||||
for _ in range(downs // 2):
|
||||
page.evaluate("() => window.scrollBy(0, window.innerHeight * 2.5)")
|
||||
page.wait_for_timeout(400)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def check_cards(c, cards, phase):
|
||||
print(f"\n{phase}: {len(cards)} cards mounted")
|
||||
c.ok(f"{phase}: cards are mounted", len(cards) > 0)
|
||||
c.ok(f"{phase}: every card's id is a loaded video",
|
||||
all(x["known"] for x in cards),
|
||||
str([x["id"] for x in cards if not x["known"]][:3]))
|
||||
|
||||
ids = [x["id"] for x in cards]
|
||||
c.ok(f"{phase}: no duplicate cards for one video", len(ids) == len(set(ids)))
|
||||
|
||||
for field in ("title", "duration", "uploader"):
|
||||
bad = [x for x in cards
|
||||
if x["known"] and (x[f"{field}_shown"] or "") != (x[f"{field}_expected"] or "")]
|
||||
c.ok(f"{phase}: {field} matches the card's own video", not bad,
|
||||
f"{len(bad)} mismatched, e.g. id={bad[0]['id']} "
|
||||
f"shown={bad[0][f'{field}_shown']!r} expected={bad[0][f'{field}_expected']!r}"
|
||||
if bad else "")
|
||||
|
||||
# The thumbnail may be served direct or through /api/image, so compare on
|
||||
# the underlying provider URL rather than the literal src.
|
||||
bad_src = [x for x in cards if x["known"] and x["src_shown"]
|
||||
and x["thumb_expected"] and x["thumb_expected"] not in x["src_shown"]
|
||||
and x["thumb_expected"].split("?")[0] not in x["src_shown"]]
|
||||
c.ok(f"{phase}: thumbnail belongs to the card's own video", not bad_src,
|
||||
f"{len(bad_src)} mismatched, e.g. id={bad_src[0]['id']}" if bad_src else "")
|
||||
|
||||
bad_heart = [x for x in cards if x["known"] and x["heart_shown"] != x["heart_expected"]]
|
||||
c.ok(f"{phase}: heart state matches the card's own video", not bad_heart,
|
||||
f"{len(bad_heart)} mismatched" if bad_heart else "")
|
||||
|
||||
stale = [x for x in cards if x["stale_loading"]]
|
||||
c.ok(f"{phase}: no card left in the loading state", not stale,
|
||||
f"{len(stale)} stuck" if stale else "")
|
||||
|
||||
# The favourite pop is animated away by animationend, which never fires on a
|
||||
# card released mid-animation -- so it can ride into the pool and replay on
|
||||
# whatever video the card is bound to next.
|
||||
popping = [x for x in cards if x["stale_pop"]]
|
||||
c.ok(f"{phase}: no card replaying the favourite animation", not popping,
|
||||
f"{len(popping)} popping, e.g. id={popping[0]['id']}" if popping else "")
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
# Lean launch flags: 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.
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
boot(page)
|
||||
|
||||
check_cards(c, page.evaluate(INSPECT), "on first render")
|
||||
|
||||
# Informational: how many videos it took to outgrow the mount window
|
||||
# varies with viewport and page size. Whether that was *enough* is
|
||||
# asserted properly at the end, on the pool's hit rate.
|
||||
print(f"\ngrew the listing to {grow(page)} videos")
|
||||
|
||||
scroll_around(page)
|
||||
check_cards(c, page.evaluate(INSPECT), "after scrolling down and back")
|
||||
|
||||
# Favouriting must land on the clicked card and survive remounting.
|
||||
page.evaluate("""() => {
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
card.querySelector('.favorite-btn').click();
|
||||
}""")
|
||||
page.wait_for_timeout(800)
|
||||
favourited = page.evaluate("() => App.favorites.getAll().map(f => f.key)")
|
||||
c.ok("favouriting stores exactly one entry", len(favourited) == 1, str(favourited))
|
||||
|
||||
scroll_around(page, downs=4)
|
||||
cards = page.evaluate(INSPECT)
|
||||
check_cards(c, cards, "after favouriting and scrolling")
|
||||
|
||||
# The menu still opens on a card that has been through the cycle.
|
||||
opened = page.evaluate("""() => {
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
card.querySelector('.video-menu-btn').click();
|
||||
return card.querySelector('.video-menu').classList.contains('open');
|
||||
}""")
|
||||
c.ok("the card menu opens after recycling", opened)
|
||||
|
||||
# Tag clicks read the button's own text now, not a data attribute.
|
||||
searched = page.evaluate("""() => {
|
||||
const tag = document.querySelector('#video-grid .video-card .video-tag');
|
||||
if (!tag) return 'no-tags';
|
||||
const label = tag.textContent;
|
||||
tag.click();
|
||||
return document.getElementById('search-input').value === label ? 'ok' : 'mismatch';
|
||||
}""")
|
||||
c.ok("clicking a tag searches for it", searched in ("ok", "no-tags"), searched)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
stats = page.evaluate("""
|
||||
() => (App.virtualGrid.stats && App.virtualGrid.stats()) || null
|
||||
""")
|
||||
if stats:
|
||||
built = stats.get("built", 0)
|
||||
recycled = stats.get("recycled", 0)
|
||||
readymade = stats.get("prepared", 0)
|
||||
total = (built + recycled + readymade) or 1
|
||||
reused = 100 * (recycled + readymade) // total
|
||||
print(f"\nmounts: {total} -- {built} built, {recycled} recycled, "
|
||||
f"{readymade} prepared ahead; {stats.get('pooled', 0)} idle in pool, "
|
||||
f"{stats.get('readied', 0)} still readied")
|
||||
# If almost everything is still built per mount, none of the checks
|
||||
# above actually exercised a reused card.
|
||||
c.ok("cards are reused rather than rebuilt", reused >= 50, f"{reused}% reused")
|
||||
c.ok("some cards were prepared before they were needed", readymade > 0,
|
||||
"prepare-ahead never served a mount")
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
124
tests/smoke_hud.py
Normal file
124
tests/smoke_hud.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The reels HUD: up when you're using it, down when you're swiping past it.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_hud.py
|
||||
|
||||
One panel and one swipe, deliberately: what's under test is when the controls
|
||||
come and go, not anything about the layout.
|
||||
|
||||
The presses here are dispatched rather than clicked, because moving a mouse is
|
||||
itself a reason to wake the HUD and would hide what is being asked.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
IDLE = """() => document.getElementById('feed-view').classList.contains('feed-hud-idle')"""
|
||||
|
||||
PRESS = """(selector) => {
|
||||
const el = document.querySelector('.feed-slide.is-active ' + selector);
|
||||
if (!el) return 'missing';
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
return document.getElementById('feed-view').classList.contains('feed-hud-idle');
|
||||
}"""
|
||||
|
||||
SWIPE = """() => {
|
||||
const s = document.getElementById('feed-scroll');
|
||||
s.scrollTop += s.clientHeight;
|
||||
}"""
|
||||
|
||||
|
||||
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",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 430, "height": 930})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
# One reload, in case the status refresh or the listing failed.
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
print("\nopening the feed")
|
||||
c.ok("the controls are up to start with", page.evaluate(IDLE) is False)
|
||||
|
||||
print("\nleaving it alone")
|
||||
page.wait_for_timeout(1600)
|
||||
c.ok("they fade on their own", page.evaluate(IDLE) is True)
|
||||
|
||||
print("\nswiping to the next reel")
|
||||
page.evaluate(SWIPE)
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("the swipe does not put them back up mid-flight", page.evaluate(IDLE) is True)
|
||||
page.wait_for_timeout(2500)
|
||||
c.ok("nor once it has landed", page.evaluate(IDLE) is True)
|
||||
c.ok("and the reel it landed on is playing",
|
||||
page.evaluate("""() => {
|
||||
const v = document.querySelector('.feed-slide.is-active .feed-video');
|
||||
return !!v && !v.paused;
|
||||
}"""))
|
||||
|
||||
print("\nwhat does bring them back")
|
||||
c.ok("pressing the video -- which is how a swipe begins -- does not",
|
||||
page.evaluate(PRESS, ".feed-video") is True)
|
||||
c.ok("pressing a control does", page.evaluate(PRESS, ".feed-pane-mute") is False)
|
||||
page.wait_for_timeout(1600)
|
||||
c.ok("after which they fade again", page.evaluate(IDLE) is True)
|
||||
page.mouse.move(200, 400)
|
||||
page.mouse.move(210, 420)
|
||||
page.wait_for_timeout(200)
|
||||
c.ok("moving the mouse does too", page.evaluate(IDLE) is False)
|
||||
|
||||
print("\na swipe started while they are up")
|
||||
c.ok("puts them away", (page.evaluate(SWIPE), page.wait_for_timeout(500),
|
||||
page.evaluate(IDLE))[2] is True)
|
||||
|
||||
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())
|
||||
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())
|
||||
170
tests/smoke_ios_pip.py
Normal file
170
tests/smoke_ios_pip.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Picture-in-picture on iOS, where the standard API does not exist.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_ios_pip.py
|
||||
|
||||
Safari on iPhone and iPad never implemented requestPictureInPicture. It has
|
||||
picture-in-picture -- it just reaches it through WebKit's older
|
||||
presentation-mode switch, and document.pictureInPictureEnabled is undefined,
|
||||
so every capability check answered "no" and the button was hidden on the one
|
||||
platform where people most want it.
|
||||
|
||||
There is no iPhone here, so the browser is reshaped to have iOS's API surface
|
||||
instead: the standard entry points are deleted and WebKit's are installed. That
|
||||
is enough to test what actually broke, because what broke was which API the
|
||||
code reaches for -- not what the browser does once it is called.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# Runs before any page script, so the app only ever sees the iOS shape.
|
||||
AS_IOS = """(() => {
|
||||
delete Document.prototype.pictureInPictureEnabled;
|
||||
delete Document.prototype.pictureInPictureElement;
|
||||
delete Document.prototype.exitPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.requestPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.disablePictureInPicture;
|
||||
delete window.documentPictureInPicture;
|
||||
|
||||
window.__pipCalls = [];
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'webkitPresentationMode', {
|
||||
configurable: true,
|
||||
get() { return this.__mode || 'inline'; },
|
||||
});
|
||||
HTMLVideoElement.prototype.webkitSupportsPresentationMode = function() { return true; };
|
||||
HTMLVideoElement.prototype.webkitSetPresentationMode = function(mode) {
|
||||
window.__pipCalls.push(mode);
|
||||
this.__mode = mode;
|
||||
// WebKit's event, which notably does not bubble.
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'));
|
||||
};
|
||||
})();"""
|
||||
|
||||
PINNED = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
return {
|
||||
calls: window.__pipCalls.slice(),
|
||||
pinned: !!(slide && App.feed.pipPinned(slide)),
|
||||
modes: Array.from(document.querySelectorAll('.feed-video'))
|
||||
.filter(v => v.webkitPresentationMode === 'picture-in-picture').length,
|
||||
video: App.state.feedActiveVideoId,
|
||||
paneVideo: slide ? slide.querySelector('.feed-pane').dataset.videoId : null,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 430, "height": 930})
|
||||
page.add_init_script(AS_IOS)
|
||||
open_reels(page)
|
||||
|
||||
print("\nthe capability check")
|
||||
c.ok("the standard API really is gone", page.evaluate(
|
||||
"() => !document.pictureInPictureEnabled && !window.documentPictureInPicture"))
|
||||
c.ok("picture-in-picture is still reported as available",
|
||||
page.evaluate("() => App.customPlayer.supportsPiP()"))
|
||||
c.ok("the button is not hidden", page.evaluate(
|
||||
"""() => { const b = document.querySelector('.feed-slide.is-active .feed-pip-btn');
|
||||
return !!b && !b.hidden; }"""))
|
||||
|
||||
print("\nthe button asks WebKit for it")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
entered = page.evaluate(PINNED)
|
||||
c.ok("it called webkitSetPresentationMode",
|
||||
entered["calls"] == ["picture-in-picture"], str(entered["calls"]))
|
||||
c.ok("exactly one video went to the window", entered["modes"] == 1, str(entered["modes"]))
|
||||
# WebKit's event does not bubble, so the feed's document-level listener
|
||||
# only hears about this if the adapter re-fires it.
|
||||
c.ok("the feed noticed and pinned the pane", entered["pinned"], str(entered))
|
||||
|
||||
print("\nmoving through the reel from the window")
|
||||
page.evaluate("() => App.feed.pipStep(1)")
|
||||
page.wait_for_timeout(3000)
|
||||
stepped = page.evaluate(PINNED)
|
||||
c.ok("the pinned pane moved to another video",
|
||||
stepped["paneVideo"] != entered["paneVideo"],
|
||||
f"{entered['paneVideo']} -> {stepped['paneVideo']}")
|
||||
c.ok("it is still the video in the window", stepped["pinned"], str(stepped))
|
||||
c.ok("and still only one", stepped["modes"] == 1, str(stepped["modes"]))
|
||||
|
||||
print("\nleaving the window")
|
||||
# What iOS does when the reader taps the window's close control.
|
||||
page.evaluate("""() => document.querySelectorAll('.feed-video').forEach((v) => {
|
||||
if (v.webkitPresentationMode === 'picture-in-picture') v.webkitSetPresentationMode('inline');
|
||||
})""")
|
||||
page.wait_for_timeout(3000)
|
||||
left = page.evaluate(PINNED)
|
||||
c.ok("the pin was released", not left["pinned"], str(left))
|
||||
c.ok("nothing is left in a window", left["modes"] == 0, str(left["modes"]))
|
||||
c.ok("the feed landed on the video the window ended on",
|
||||
left["video"] == stepped["paneVideo"],
|
||||
f"{stepped['paneVideo']} -> {left['video']}")
|
||||
c.ok("no video is shown twice after the rebuild", page.evaluate(
|
||||
"""() => { const ids = Array.from(document.querySelectorAll('.feed-pane'))
|
||||
.map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size; }"""))
|
||||
|
||||
print("\nthe button toggles back off")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
toggled = page.evaluate(PINNED)
|
||||
c.ok("the second press asked to go back inline",
|
||||
toggled["calls"][-1] == "inline", str(toggled["calls"]))
|
||||
c.ok("nothing is left in a window", toggled["modes"] == 0, str(toggled["modes"]))
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
179
tests/smoke_ios_split.py
Normal file
179
tests/smoke_ios_split.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_ios_split.py
|
||||
|
||||
There is no iPhone here, and the bug was never about what the video element
|
||||
does once asked -- it is about what iOS does to the *other* video when one
|
||||
starts with sound. So the rule is installed into Chromium and the real feed is
|
||||
driven through it: unmuting a panel used to unmute every panel, and the system
|
||||
would then stop all but one of them.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
|
||||
|
||||
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
|
||||
# unmutes) with sound takes the audio session, and whatever had it is paused.
|
||||
AS_IOS = """(() => {
|
||||
window.__systemPaused = [];
|
||||
const claimAudio = (winner) => {
|
||||
document.querySelectorAll('video').forEach((other) => {
|
||||
if (other === winner || other.paused || other.muted) return;
|
||||
window.__systemPaused.push(other.currentSrc || 'video');
|
||||
HTMLMediaElement.prototype.pause.call(other);
|
||||
});
|
||||
};
|
||||
const play = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = function() {
|
||||
if (!this.muted) claimAudio(this);
|
||||
return play.apply(this, arguments);
|
||||
};
|
||||
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
|
||||
configurable: true,
|
||||
get() { return muted.get.call(this); },
|
||||
set(value) {
|
||||
muted.set.call(this, value);
|
||||
if (!value && !this.paused) claimAudio(this);
|
||||
},
|
||||
});
|
||||
})();"""
|
||||
|
||||
PANES = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
if (!slide) return null;
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
return panes.map((pane) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
const btn = pane.querySelector('.feed-pane-mute');
|
||||
return {
|
||||
muted: video ? video.muted : null,
|
||||
flag: pane._muted === undefined ? null : !!pane._muted,
|
||||
paused: video ? video.paused : null,
|
||||
loaded: pane.classList.contains('is-loaded'),
|
||||
button: btn ? btn.textContent : '',
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
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 open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate("""([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}""", [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(4000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
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",
|
||||
])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 430, "height": 930},
|
||||
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
|
||||
device_scale_factor=3)
|
||||
context.add_init_script(AS_IOS)
|
||||
page = context.new_page()
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
open_reels(page)
|
||||
|
||||
print("\nthe rule is really in force")
|
||||
c.ok("the page says it is an iPhone",
|
||||
"iPhone" in page.evaluate("() => navigator.userAgent"))
|
||||
|
||||
print("\nsplit in two, with sound on")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
|
||||
page.wait_for_timeout(5000)
|
||||
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
|
||||
page.wait_for_timeout(3000)
|
||||
panes = page.evaluate(PANES)
|
||||
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
|
||||
audible = [p for p in panes if p["muted"] is False]
|
||||
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
|
||||
c.ok("and the other says so on its own button",
|
||||
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
|
||||
c.ok("the flags agree with the elements",
|
||||
all(p["flag"] == p["muted"] for p in panes), str(panes))
|
||||
c.ok("both panels are playing",
|
||||
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
|
||||
|
||||
print("\nmoving the sound to the other panel")
|
||||
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
|
||||
page.wait_for_timeout(2500)
|
||||
moved = page.evaluate(PANES)
|
||||
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
|
||||
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
|
||||
c.ok("and nothing stopped playing",
|
||||
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
|
||||
|
||||
print("\nswiping on")
|
||||
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
page.wait_for_timeout(5000)
|
||||
stepped = page.evaluate(PANES)
|
||||
c.ok("the next step plays in both panels",
|
||||
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
|
||||
str(stepped))
|
||||
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
|
||||
str(stepped))
|
||||
|
||||
# Without the fix, both panels would be unmuted -- so prove the rule
|
||||
# installed above actually bites, by breaking it on purpose.
|
||||
print("\nthe simulated rule is what the fix is for")
|
||||
page.evaluate("""() => {
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
|
||||
v.muted = false;
|
||||
const p = v.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
});
|
||||
}""")
|
||||
page.wait_for_timeout(1500)
|
||||
forced = page.evaluate(PANES)
|
||||
c.ok("unmuting every panel does stop one of them",
|
||||
any(p["paused"] for p in forced if p["loaded"]), str(forced))
|
||||
|
||||
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())
|
||||
276
tests/smoke_open.py
Normal file
276
tests/smoke_open.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opening a video: load first, show the player once there's something to show.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_open.py
|
||||
|
||||
Both the listing and the media are served by this script, so "the stream is
|
||||
slow" and "the stream is broken" are conditions the test can actually create
|
||||
rather than wait for.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
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"
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the point of the
|
||||
# test is that the browser decodes a frame from it.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
|
||||
def make_server(delay_holder):
|
||||
"""Serves the fixture video, optionally after a delay, plus a dead URL."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/slow") or self.path.startswith("/fast"):
|
||||
time.sleep(delay_holder["slow"] if self.path.startswith("/slow") else 0)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
STATE = """() => {
|
||||
const player = document.getElementById('custom-player');
|
||||
const video = player.querySelector('.cp-video');
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
return {
|
||||
shown: player.classList.contains('open'),
|
||||
preloading: player.classList.contains('is-preloading'),
|
||||
active: !!(App.player.isActive && App.player.isActive()),
|
||||
cardBusy: !!(card && card.classList.contains('is-loading')),
|
||||
src: video ? (video.currentSrc || video.getAttribute('src') || '') : '',
|
||||
readyState: video ? video.readyState : -1,
|
||||
muted: video ? video.muted : null,
|
||||
bodyOverflow: document.body.style.overflow,
|
||||
error: !!(player.querySelector('.cp-error') && !player.querySelector('.cp-error').hidden),
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
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 item(video_url, ident="test:0"):
|
||||
"""One listing item whose formats are already resolved, so opening it goes
|
||||
straight to playback rather than through a resolve first."""
|
||||
return {
|
||||
"id": ident,
|
||||
"title": "A video",
|
||||
"url": "https://example.test/watch",
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": video_url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": video_url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def open_first(page):
|
||||
"""Click the first card. Forced, because the hover preview parks a <video>
|
||||
over the thumbnail -- which is inside the card and so opens it just the
|
||||
same, but Playwright won't click through it on its own."""
|
||||
page.click("#video-grid .video-card", force=True)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
delay = {"slow": 0}
|
||||
httpd = make_server(delay)
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
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": 1400, "height": 1000})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
items = {"body": [item(f"{media}/fast.mp4")]}
|
||||
page.route("**/api/videos", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"items": items["body"], "pageInfo": {"hasNextPage": False}})))
|
||||
|
||||
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(800)
|
||||
|
||||
print("\na video that loads quickly")
|
||||
open_first(page)
|
||||
page.wait_for_timeout(2500)
|
||||
after = page.evaluate(STATE)
|
||||
c.ok("the player is on screen", after["shown"], str(after))
|
||||
c.ok("it is no longer preloading", not after["preloading"], str(after))
|
||||
c.ok("with data, not an empty frame", after["readyState"] >= 2, str(after["readyState"]))
|
||||
c.ok("the card has stopped spinning", not after["cardBusy"], str(after))
|
||||
c.ok("sound is back on", after["muted"] is False, str(after["muted"]))
|
||||
c.ok("and the page behind it is locked", after["bodyOverflow"] == "hidden",
|
||||
after["bodyOverflow"])
|
||||
page.evaluate("() => App.player.close()")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
print("\na video that takes its time")
|
||||
delay["slow"] = 3.0
|
||||
items["body"] = [item(f"{media}/slow.mp4", "test:slow")]
|
||||
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
page.wait_for_timeout(600)
|
||||
open_first(page)
|
||||
page.wait_for_timeout(900)
|
||||
during = page.evaluate(STATE)
|
||||
c.ok("the player is not up yet", not during["shown"], str(during))
|
||||
c.ok("but the session is live", during["active"] and during["preloading"], str(during))
|
||||
c.ok("the card says it is working on it", during["cardBusy"], str(during))
|
||||
c.ok("nothing can be heard from it", during["muted"] is True, str(during["muted"]))
|
||||
c.ok("and the page is still the page", during["bodyOverflow"] != "hidden",
|
||||
during["bodyOverflow"])
|
||||
# The grid is still usable underneath: the invisible player must not be
|
||||
# eating clicks.
|
||||
c.ok("the grid underneath is still reachable",
|
||||
page.evaluate("""() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
return !!(el && !el.closest('#custom-player'));
|
||||
}"""))
|
||||
page.wait_for_timeout(4000)
|
||||
later = page.evaluate(STATE)
|
||||
c.ok("once the data lands, the player appears", later["shown"], str(later))
|
||||
c.ok("and the card is released", not later["cardBusy"], str(later))
|
||||
page.evaluate("() => App.player.close()")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
print("\nchanging your mind while it loads")
|
||||
delay["slow"] = 8.0
|
||||
open_first(page)
|
||||
page.wait_for_timeout(700)
|
||||
c.ok("Escape cancels a pending open",
|
||||
page.evaluate("""() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return true;
|
||||
}"""))
|
||||
page.wait_for_timeout(600)
|
||||
cancelled = page.evaluate(STATE)
|
||||
c.ok("nothing is left running", not cancelled["active"], str(cancelled))
|
||||
c.ok("the player never appeared", not cancelled["shown"], str(cancelled))
|
||||
c.ok("and the card is free again", not cancelled["cardBusy"], str(cancelled))
|
||||
|
||||
print("\na video that will not load at all")
|
||||
items["body"] = [item(f"{media}/nope.mp4", "test:dead")]
|
||||
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
page.wait_for_timeout(600)
|
||||
open_first(page)
|
||||
page.wait_for_timeout(6000)
|
||||
failed = page.evaluate(STATE)
|
||||
c.ok("the player is shown so the failure can be read", failed["shown"], str(failed))
|
||||
c.ok("with its error up", failed["error"], str(failed))
|
||||
c.ok("and the card no longer spins", not failed["cardBusy"], str(failed))
|
||||
page.evaluate("() => App.player.close()")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
httpd.shutdown()
|
||||
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())
|
||||
332
tests/smoke_parallel.py
Normal file
332
tests/smoke_parallel.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A split step loads its videos together and ahead of time, not one by one.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_parallel.py
|
||||
|
||||
The listing and the media are fixtures, so what is measured is the feed and not
|
||||
somebody's CDN: every clip takes the same MEDIA_DELAY_MS to arrive, which is
|
||||
far longer than a step that was preloaded needs to start.
|
||||
|
||||
Four panels, which is the case this is for. With one step preloaded between
|
||||
them, a panel did its loading at the moment it was swiped to -- on a real CDN
|
||||
that is four starts in a row, and here it is four late ones. The second swipe
|
||||
is where it shows: the first lands on the step that was preloaded either way,
|
||||
and it comes quickly enough that the step after it cannot have loaded since.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
PER_PAGE = 12
|
||||
PAGES = 8
|
||||
MEDIA_DELAY_MS = 2000 # what a clip costs if it isn't already loading
|
||||
READY_MS = 400 # a start this quick means the clip was already there
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the panels have to
|
||||
# decode a frame from it. Same blob as smoke_open.py.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
def make_server():
|
||||
"""Serves the fixture clip, after a delay, however many at a time."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
time.sleep(MEDIA_DELAY_MS / 1000)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
# Which videos were asked to resolve their formats. Prefilled formats make the
|
||||
# answer immediate, but the asking is what says when the feed goes looking.
|
||||
SPY = """() => {
|
||||
window.__resolved = [];
|
||||
const real = App.videos.ensureFormats;
|
||||
App.videos.ensureFormats = function(video) {
|
||||
if (video && video.id) window.__resolved.push(String(video.id));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
}"""
|
||||
|
||||
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
|
||||
step: Number(slide.dataset.step),
|
||||
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
|
||||
.map((pane) => pane.classList.contains('is-loaded')),
|
||||
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
|
||||
}))"""
|
||||
|
||||
WATCH = """() => {
|
||||
window.__t0 = performance.now();
|
||||
window.__started = {};
|
||||
document.addEventListener('playing', (e) => {
|
||||
const pane = e.target.closest && e.target.closest('.feed-pane');
|
||||
if (!pane) return;
|
||||
const id = pane.dataset.videoId;
|
||||
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
|
||||
}, true);
|
||||
}"""
|
||||
|
||||
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click(); // dispatched, so the pointer never wakes the HUD
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
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 make_items(page_no, media):
|
||||
"""One page of the listing, formats already resolved so nothing but the
|
||||
media itself stands between a panel and its first frame."""
|
||||
start = (page_no - 1) * PER_PAGE
|
||||
items = []
|
||||
for i in range(PER_PAGE):
|
||||
n = start + i
|
||||
url = f"{media}/v{n}.mp4"
|
||||
items.append({
|
||||
"id": f"fix-{n}",
|
||||
"title": f"Fixture {n}",
|
||||
"url": "https://example.test/watch/%d" % n,
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
httpd = make_server()
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
# Listing requests are parked as they arrive and answered a beat later, so
|
||||
# how many the feed has out at once is something the test can see.
|
||||
parked = []
|
||||
busiest = {"max": 0}
|
||||
|
||||
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=768",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 800, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
def park(route):
|
||||
parked.append(route)
|
||||
busiest["max"] = max(busiest["max"], len(parked))
|
||||
|
||||
def answer():
|
||||
while parked:
|
||||
route = parked.pop(0)
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
page_no = int(body.get("page", 1) or 1)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"items": make_items(page_no, media) if page_no <= PAGES else [],
|
||||
"pageInfo": {"hasNextPage": page_no < PAGES},
|
||||
}))
|
||||
|
||||
def settle(ms):
|
||||
"""Let the page run, answering listing requests as they pile up."""
|
||||
for _ in range(max(1, ms // 100)):
|
||||
page.wait_for_timeout(100)
|
||||
answer()
|
||||
|
||||
page.route("**/api/videos", park)
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
settle(1500)
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.evaluate(SPY)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
settle(3000)
|
||||
|
||||
print("\nsplitting the step four ways")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-right")
|
||||
settle(1500)
|
||||
for nth in (1, 2):
|
||||
page.evaluate(CLICK, f".feed-slide.is-active .feed-pane:nth-of-type({nth}) .feed-pane-split-down")
|
||||
settle(1500)
|
||||
settle(3000)
|
||||
per = page.evaluate("() => App.feed.paneCount()")
|
||||
c.ok("four panels", per == 4, str(per))
|
||||
|
||||
panes = page.evaluate(PANES)
|
||||
active = page.evaluate("() => App.state.feedActiveIndex")
|
||||
ahead = [s for s in panes if s["step"] > active]
|
||||
loaded_ahead = sum(1 for s in ahead for is_loaded in s["loaded"] if is_loaded)
|
||||
want = per * 3
|
||||
print(f" ({loaded_ahead} videos loading ahead, over {len(ahead)} steps)")
|
||||
c.ok(f"every panel keeps its next three videos ready ({want} of them)",
|
||||
loaded_ahead >= want, f"loaded {loaded_ahead} of {sum(len(s['loaded']) for s in ahead)}")
|
||||
|
||||
resolved = set(page.evaluate("() => window.__resolved"))
|
||||
missing = [i for s in ahead for i in s["ids"] if i not in resolved]
|
||||
c.ok("and their formats were asked for up front, not at load time",
|
||||
not missing, f"never resolved: {missing[:4]}")
|
||||
|
||||
def swipe_and_time(label, watch_ms):
|
||||
page.evaluate(WATCH)
|
||||
page.evaluate(SWIPE)
|
||||
settle(watch_ms)
|
||||
started = page.evaluate("() => window.__started")
|
||||
ids = page.evaluate("""() => Array.from(
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-pane')).map(p => p.dataset.videoId)""")
|
||||
times = [started[i] for i in ids if i in started]
|
||||
print(f" ({label}: started at {sorted(times)} ms after the swipe)")
|
||||
return ids, times
|
||||
|
||||
print("\nswiping to the next step")
|
||||
# Measured briefly, and on purpose: the step after this one must not get
|
||||
# a quiet moment to load in, or the swipe below tests nothing.
|
||||
ids, times = swipe_and_time("first swipe", 800)
|
||||
c.ok("it landed on the next step",
|
||||
page.evaluate("() => App.state.feedActiveIndex") == active + 1)
|
||||
c.ok("all four panels started, together",
|
||||
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
|
||||
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
|
||||
|
||||
print("\nand straight on to the one after it")
|
||||
ids, times = swipe_and_time("second swipe", 3000)
|
||||
c.ok("it landed there too",
|
||||
page.evaluate("() => App.state.feedActiveIndex") == active + 2)
|
||||
c.ok("every panel of it is playing",
|
||||
page.evaluate("""() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-video'))
|
||||
.every(v => !v.paused)"""))
|
||||
c.ok("all four started, together",
|
||||
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
|
||||
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
|
||||
c.ok(f"and at once, because the clips were already there (a clip takes {MEDIA_DELAY_MS}ms)",
|
||||
times and max(times) < READY_MS,
|
||||
f"slowest {max(times) if times else 'n/a'}ms")
|
||||
|
||||
print("\nfeeding the split")
|
||||
c.ok("more than one page of listing is fetched at a time",
|
||||
busiest["max"] > 1, f"most at once: {busiest['max']}")
|
||||
now_active = page.evaluate("() => App.state.feedActiveIndex")
|
||||
c.ok("and the buffer stays ahead of what a swipe eats",
|
||||
page.evaluate("() => App.state.loadedVideos.length") > (now_active + 1) * per + per * 3,
|
||||
str(page.evaluate("() => App.state.loadedVideos.length")))
|
||||
|
||||
print("\nclosing a panel")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
|
||||
settle(2000)
|
||||
c.ok("what is left plays on", page.evaluate("""() => {
|
||||
const v = document.querySelector('.feed-slide.is-active .feed-video');
|
||||
return !!v && !v.paused;
|
||||
}"""))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
httpd.shutdown()
|
||||
|
||||
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())
|
||||
148
tests/smoke_peek.py
Normal file
148
tests/smoke_peek.py
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reels peeking: the next step plays as it slides in, and rewinds if it doesn't.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_peek.py
|
||||
|
||||
A half-finished swipe is the thing under test, so the scroller's snapping is
|
||||
switched off for the duration: a finger holds the list between two steps, and
|
||||
`scrollTop` can only do the same with mandatory snapping out of the way.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# Every step in the window, by its index, with what its video is doing.
|
||||
STEPS = """() => {
|
||||
const out = {};
|
||||
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
out[slide.dataset.step] = {
|
||||
active: slide.classList.contains('is-active'),
|
||||
loaded: !!slide.querySelector('.feed-pane.is-loaded'),
|
||||
paused: video ? video.paused : null,
|
||||
muted: video ? video.muted : null,
|
||||
t: video ? Math.round(video.currentTime * 10) / 10 : null,
|
||||
};
|
||||
});
|
||||
return { steps: out, activeIndex: App.state.feedActiveIndex };
|
||||
}"""
|
||||
|
||||
DRAG = """(fraction) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
// A finger can hold the list between two steps; scrollTop can only do the
|
||||
// same with mandatory snapping out of the way.
|
||||
scroller.style.scrollSnapType = 'none';
|
||||
const height = scroller.clientHeight;
|
||||
scroller.scrollTop = Math.round(height * fraction);
|
||||
return scroller.scrollTop;
|
||||
}"""
|
||||
|
||||
|
||||
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 open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(4000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
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": 430, "height": 930})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
open_reels(page)
|
||||
|
||||
start = page.evaluate(STEPS)
|
||||
c.ok("the first step is playing", start["steps"]["0"]["paused"] is False, str(start))
|
||||
|
||||
print("\na swipe barely begun")
|
||||
page.evaluate(DRAG, 0.18)
|
||||
page.wait_for_timeout(1800)
|
||||
peek = page.evaluate(STEPS)
|
||||
nxt = peek["steps"].get("1")
|
||||
c.ok("the step sliding in is already playing", nxt and nxt["paused"] is False, str(peek))
|
||||
c.ok("silently, so it doesn't talk over the one being left",
|
||||
nxt and nxt["muted"] is True, str(nxt))
|
||||
c.ok("and the one being left plays on",
|
||||
peek["steps"]["0"]["paused"] is False, str(peek["steps"]["0"]))
|
||||
c.ok("without the swipe counting as done", peek["activeIndex"] == 0,
|
||||
str(peek["activeIndex"]))
|
||||
|
||||
print("\nthinking better of it")
|
||||
page.evaluate(DRAG, 0)
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => App.feed._settlePeek()")
|
||||
page.wait_for_timeout(400)
|
||||
back = page.evaluate(STEPS)
|
||||
nxt = back["steps"].get("1")
|
||||
c.ok("the step that slid back in is stopped", nxt and nxt["paused"] is True, str(nxt))
|
||||
c.ok("and wound back to the start", nxt and nxt["t"] == 0, str(nxt))
|
||||
c.ok("the one being watched never stopped",
|
||||
back["steps"]["0"]["paused"] is False, str(back["steps"]["0"]))
|
||||
|
||||
print("\nswiping again starts it from the top")
|
||||
page.evaluate(DRAG, 0.2)
|
||||
page.wait_for_timeout(1500)
|
||||
again = page.evaluate(STEPS)["steps"].get("1")
|
||||
c.ok("it plays again", again and again["paused"] is False, str(again))
|
||||
c.ok("from the beginning, not from where the first peek got to",
|
||||
again and again["t"] is not None and again["t"] < 2.5, str(again))
|
||||
|
||||
print("\ncarrying the swipe through")
|
||||
page.evaluate(DRAG, 1)
|
||||
page.wait_for_timeout(2500)
|
||||
page.evaluate("() => App.feed._settlePeek()")
|
||||
page.wait_for_timeout(400)
|
||||
done = page.evaluate(STEPS)
|
||||
arrived = done["steps"].get("1")
|
||||
c.ok("the step arrived at is the active one", done["activeIndex"] == 1, str(done["activeIndex"]))
|
||||
c.ok("it is playing", arrived and arrived["paused"] is False, str(arrived))
|
||||
c.ok("it was never wound back", arrived and arrived["t"] > 0, str(arrived))
|
||||
c.ok("and it carries the feed's own sound setting",
|
||||
arrived and arrived["muted"] == page.evaluate("() => App.state.feedMuted !== false"),
|
||||
str(arrived))
|
||||
|
||||
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())
|
||||
292
tests/smoke_poster_black.py
Normal file
292
tests/smoke_poster_black.py
Normal file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A captured thumbnail has to be a picture of something.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_poster_black.py
|
||||
|
||||
Two ways a card ends up a black box rather than a picture, both of which the
|
||||
capture used to keep for the life of the item -- the only test a frame had to
|
||||
pass was being a few hundred bytes long:
|
||||
|
||||
* the frame at the mark is black. A video that opens on a fade, or whose
|
||||
length nobody will say (so there is no quarter mark to seek to and the
|
||||
opening frame is all there is), hands over exactly that.
|
||||
* the element decoded nothing. Drawing one that has not presented a frame
|
||||
gives an untouched canvas, which is a pure black JPEG about 1.6 KB long.
|
||||
|
||||
The fixture is black for its first three and a half seconds -- past the quarter
|
||||
mark a capture aims for -- and flat orange after, so the picture on the card
|
||||
says whether anything looked at what it was keeping.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "dark.mp4")
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
CACHE_KEY = "posterCache"
|
||||
|
||||
STATUS = {
|
||||
"online": True,
|
||||
"channels": [{
|
||||
"id": CHANNEL,
|
||||
"name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.",
|
||||
"options": [],
|
||||
}],
|
||||
}
|
||||
|
||||
# Saved with the thumbnail it had at the time, and with nothing known about how
|
||||
# long it is -- which is the state the item this test comes from is in.
|
||||
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: 0,
|
||||
isLive: false,
|
||||
favoriteDate: new Date().toISOString()
|
||||
}]));
|
||||
}"""
|
||||
|
||||
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;
|
||||
}"""
|
||||
|
||||
# What the card is showing, measured: the brightest sample in it and the
|
||||
# average, which is all it takes to tell a picture from a black box.
|
||||
LIGHT = """(id) => new Promise((resolve) => {
|
||||
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
|
||||
const img = card ? card.querySelector('.video-thumb img') : null;
|
||||
const src = img ? (img.getAttribute('src') || '') : '';
|
||||
if (!src) { resolve(null); return; }
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = probe.naturalWidth;
|
||||
canvas.height = probe.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(probe, 0, 0);
|
||||
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let sum = 0, brightest = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const value = Math.max(px[i], px[i + 1], px[i + 2]);
|
||||
sum += value;
|
||||
if (value > brightest) brightest = value;
|
||||
}
|
||||
resolve({ brightest, mean: Math.round(sum / (px.length / 4)),
|
||||
bytes: src.length, w: canvas.width, h: canvas.height });
|
||||
};
|
||||
probe.onerror = () => resolve(null);
|
||||
probe.src = src;
|
||||
})"""
|
||||
|
||||
CACHE = """() => JSON.parse(localStorage.getItem('posterCache') || '[]')"""
|
||||
|
||||
FORGET = """() => {
|
||||
localStorage.removeItem('posterCache');
|
||||
Object.keys(localStorage).forEach((k) => {
|
||||
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
|
||||
});
|
||||
}"""
|
||||
|
||||
# The item this test comes from has no duration: the extractor reports none and
|
||||
# the favorite kept a zero. Where the browser can't supply one either, there is
|
||||
# no quarter mark to seek to -- and the frame the element decodes on its own is
|
||||
# the opening one, which is the frame this clip is black for.
|
||||
NO_DURATION = """
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'duration', {
|
||||
configurable: true,
|
||||
get: function() { return NaN; }
|
||||
});
|
||||
"""
|
||||
|
||||
# What the version before this kept: an untouched canvas, JPEG'd. Written over
|
||||
# the picture in the cache, with the "this was looked at" marker taken off the
|
||||
# index entry, which is exactly the state those sessions left behind.
|
||||
POISON = """() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 360;
|
||||
canvas.height = 203;
|
||||
const black = canvas.toDataURL('image/jpeg', 0.72);
|
||||
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
|
||||
index.forEach((entry) => {
|
||||
delete entry.v;
|
||||
entry.n = black.length;
|
||||
localStorage.setItem(`posterCache:${entry.k}`, black);
|
||||
});
|
||||
localStorage.setItem('posterCache', JSON.stringify(index));
|
||||
return { black: black, entries: index.length };
|
||||
}"""
|
||||
|
||||
|
||||
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():
|
||||
return [{"id": "vid-0", "title": "clip 0",
|
||||
"url": "https://fixture.test/v0.mp4",
|
||||
"thumb": "https://pictures.fixture.test/gone/0.jpg",
|
||||
"channel": CHANNEL, "duration": 0, "isLive": False, "tags": []}]
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
streamed = []
|
||||
|
||||
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}})))
|
||||
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=""))
|
||||
page.route(re.compile(r"/api/poster"), lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({"thumb": ""})))
|
||||
|
||||
# Byte ranges and all: a response a browser cannot seek in is one it
|
||||
# will not take a frame out of the middle of.
|
||||
def stream(route):
|
||||
streamed.append(route.request.url)
|
||||
body = open(CLIP, "rb").read()
|
||||
span = re.match(r"bytes=(\d+)-(\d*)", route.request.headers.get("range") or "")
|
||||
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 quarter mark of this clip is black")
|
||||
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
|
||||
shot = page.evaluate(LIGHT, "vid-0")
|
||||
c.ok("the card ended up with a captured picture", shot is not None, str(shot))
|
||||
c.ok("and it is not a black box",
|
||||
shot and shot["brightest"] > 40, str(shot))
|
||||
c.ok("it is the orange the clip shows past its lead-in",
|
||||
shot and shot["mean"] > 60, str(shot))
|
||||
c.ok("at card width, so it is a thumbnail and not a frame of video",
|
||||
shot and shot["w"] == 360, str(shot))
|
||||
|
||||
print("\nand the picture it kept says it was looked at")
|
||||
cached = page.evaluate(CACHE)
|
||||
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
|
||||
c.ok("marked as checked", cached and cached[0].get("v") == 1, str(cached))
|
||||
|
||||
print("\na black picture kept by an earlier version is replaced")
|
||||
poisoned = page.evaluate(POISON)
|
||||
c.ok("the cache now holds what that version would have kept",
|
||||
poisoned["entries"] == 1, str(poisoned["entries"]))
|
||||
streamed.clear()
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
# A flat frame weighs about what a blank one does -- both are a single
|
||||
# colour -- so what says the card moved on is the picture itself.
|
||||
page.wait_for_function(
|
||||
"""([id, black]) => {
|
||||
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
|
||||
const img = card && card.querySelector('.video-thumb img');
|
||||
const src = img ? (img.getAttribute('src') || '') : '';
|
||||
// The listed picture is on the card while the ladder walks
|
||||
// down it, so what counts is a captured picture that is not
|
||||
// the one that was there.
|
||||
return src.indexOf('data:image/jpeg') === 0 && src !== black;
|
||||
}""", arg=["vid-0", poisoned["black"]], timeout=60000)
|
||||
healed = page.evaluate(LIGHT, "vid-0")
|
||||
c.ok("the card shows a picture again", healed is not None, str(healed))
|
||||
c.ok("not the black one it was given", healed and healed["brightest"] > 40, str(healed))
|
||||
c.ok("taken from the video, which was fetched for it",
|
||||
any("/api/stream" in url for url in streamed), str(streamed[:2]))
|
||||
kept = page.evaluate(CACHE)
|
||||
c.ok("and the replacement is what is kept now",
|
||||
len(kept) == 1 and kept[0].get("v") == 1, str(kept))
|
||||
|
||||
print("\nand it is not measured a second time")
|
||||
streamed.clear()
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.wait_for_timeout(2000)
|
||||
again = page.evaluate(LIGHT, "vid-0")
|
||||
c.ok("the kept picture is on the card straight away",
|
||||
again and again["brightest"] > 40, str(again))
|
||||
c.ok("without going back to the video", streamed == [], str(streamed))
|
||||
|
||||
print("\nand a clip nobody will say the length of is not left on frame one")
|
||||
page.evaluate(FORGET)
|
||||
page.add_init_script(NO_DURATION)
|
||||
streamed.clear()
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
c.ok("the browser gives no length to go by",
|
||||
page.evaluate("() => String(document.createElement('video').duration)") == 'NaN')
|
||||
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
|
||||
blind = page.evaluate(LIGHT, "vid-0")
|
||||
c.ok("a frame came out of the video anyway", blind is not None, str(blind))
|
||||
c.ok("and it is not the black one it opens on",
|
||||
blind and blind["brightest"] > 40, str(blind))
|
||||
|
||||
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())
|
||||
308
tests/smoke_poster_capture.py
Normal file
308
tests/smoke_poster_capture.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/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))
|
||||
|
||||
print("\nand the reader can have them taken again")
|
||||
streamed.clear()
|
||||
page.click(".settings-toggle")
|
||||
c.ok("the setting says what is being kept",
|
||||
"1 picture kept" in page.inner_text("#clear-posters-status"),
|
||||
page.inner_text("#clear-posters-status"))
|
||||
page.click("#clear-posters-btn")
|
||||
c.ok("nothing is kept any more", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
|
||||
c.ok("and it says what it threw away",
|
||||
"Threw away 1 picture" in page.inner_text("#clear-posters-status"),
|
||||
page.inner_text("#clear-posters-status"))
|
||||
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
|
||||
shot = page.evaluate(SHOT, "vid-0")
|
||||
c.ok("the card has a frame out of the video again",
|
||||
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
|
||||
c.ok("which was fetched for it, this being the point of the setting",
|
||||
any("/api/stream" in url for url in streamed), str(streamed[:2]))
|
||||
c.ok("and kept again, as one picture",
|
||||
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
|
||||
|
||||
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())
|
||||
141
tests/smoke_privacy.py
Normal file
141
tests/smoke_privacy.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The privacy veil: the page blurs whenever this window is not the one in front.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_privacy.py
|
||||
|
||||
Headless Chromium keeps every tab visible and focused -- opening a second tab
|
||||
and bringing it to the front changes neither, and neither does freezing the
|
||||
page over CDP -- so there is no way to genuinely send this window to the back.
|
||||
The transitions are therefore dispatched by hand, which tests what this change
|
||||
owns -- what the page does when the browser says it has gone away -- and not
|
||||
what the browser does with a backgrounded tab.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
|
||||
VEILED = """() => {
|
||||
const veil = document.getElementById('privacy-veil');
|
||||
return !!veil && veil.classList.contains('is-on');
|
||||
}"""
|
||||
|
||||
OPACITY = """() => getComputedStyle(document.getElementById('privacy-veil')).opacity"""
|
||||
|
||||
# What a tap in the middle of the window would land on.
|
||||
UNDER_FINGER = """() => {
|
||||
const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
|
||||
return el ? el.id || el.className || el.tagName : null;
|
||||
}"""
|
||||
|
||||
# Neither a second tab nor CDP can put this window in the background, so the
|
||||
# two halves of a tab switch are announced the way the browser would announce
|
||||
# them.
|
||||
HIDE = """() => {
|
||||
Object.defineProperty(document, 'visibilityState',
|
||||
{ configurable: true, get: () => 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}"""
|
||||
|
||||
SHOW = """() => {
|
||||
Object.defineProperty(document, 'visibilityState',
|
||||
{ configurable: true, get: () => 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}"""
|
||||
|
||||
# The highest z-index anywhere in the app's own stylesheet, veil excluded: the
|
||||
# veil has to beat the player and the reels feed, not just the page behind them.
|
||||
HIGHEST_OTHER = """() => {
|
||||
let top = 0;
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try { rules = sheet.cssRules; } catch (err) { continue; }
|
||||
for (const rule of rules || []) {
|
||||
if (!rule.selectorText || rule.selectorText.includes('privacy-veil')) continue;
|
||||
const z = parseInt(rule.style && rule.style.zIndex, 10);
|
||||
if (!isNaN(z) && z > top) top = z;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}"""
|
||||
|
||||
|
||||
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",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 430, "height": 930})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_function("() => window.App && App.privacy && App.privacy.isVeiled", timeout=30000)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
print("\nthe window in front")
|
||||
c.ok("nothing is veiled", page.evaluate(VEILED) is False)
|
||||
c.ok("and the veil is out of the way", page.evaluate(OPACITY) == "0")
|
||||
c.ok("so taps reach the page", page.evaluate(UNDER_FINGER) != "privacy-veil",
|
||||
str(page.evaluate(UNDER_FINGER)))
|
||||
|
||||
print("\nthe tab going to the back")
|
||||
page.evaluate(HIDE)
|
||||
c.ok("the page goes behind the blur", page.evaluate(VEILED) is True)
|
||||
c.ok("fully, and at once", page.evaluate(OPACITY) == "1")
|
||||
c.ok("with nothing underneath reachable", page.evaluate(UNDER_FINGER) == "privacy-veil",
|
||||
str(page.evaluate(UNDER_FINGER)))
|
||||
c.ok("and it sits above everything the app can show",
|
||||
page.evaluate("() => parseInt(getComputedStyle(document.getElementById('privacy-veil')).zIndex, 10)")
|
||||
> page.evaluate(HIGHEST_OTHER),
|
||||
str(page.evaluate(HIGHEST_OTHER)))
|
||||
|
||||
print("\ncoming back to it")
|
||||
page.evaluate(SHOW)
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("the blur lifts", page.evaluate(VEILED) is False)
|
||||
|
||||
print("\nlosing focus without leaving the tab -- another window, say")
|
||||
page.evaluate("() => window.dispatchEvent(new Event('blur'))")
|
||||
c.ok("the page goes behind the blur too", page.evaluate(VEILED) is True)
|
||||
page.evaluate("() => window.dispatchEvent(new Event('focus'))")
|
||||
c.ok("and comes back when focus does", page.evaluate(VEILED) is False)
|
||||
|
||||
print("\na touch on the veil")
|
||||
page.evaluate("() => window.dispatchEvent(new Event('blur'))")
|
||||
c.ok("veiled to start with", page.evaluate(VEILED) is True)
|
||||
page.evaluate("""() => document.getElementById('privacy-veil')
|
||||
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))""")
|
||||
c.ok("lifts it rather than pressing what is underneath", page.evaluate(VEILED) is False)
|
||||
|
||||
print("\nthe tab going away altogether")
|
||||
page.evaluate("() => window.dispatchEvent(new Event('pagehide'))")
|
||||
c.ok("leaves it blurred behind us", page.evaluate(VEILED) is True)
|
||||
|
||||
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())
|
||||
176
tests/smoke_reels.py
Executable file
176
tests/smoke_reels.py
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reels split-panel smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_reels.py
|
||||
|
||||
The load-bearing check is the preload guarantee: every panel must have its
|
||||
next video buffered before the reader swipes. With locked scrolling that is
|
||||
the same panel position in the next step, so it holds only while the next
|
||||
step is both built and preloaded for all of its panes -- which in turn rests
|
||||
on the floor of one step in windowBounds() and in setActive()'s preloadAhead.
|
||||
Both shrink as panes are added, and without the floor a wide split would
|
||||
leave panels with nothing to swipe to.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
LAYOUT = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
if (!slide) return null;
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
const bounds = slide.getBoundingClientRect();
|
||||
return {
|
||||
count: panes.length,
|
||||
reported: App.feed.paneCount(),
|
||||
videos: panes.map(p => p.dataset.videoId),
|
||||
// Controls positioned for a full viewport end up outside a short pane,
|
||||
// clipped by its overflow and unreachable.
|
||||
escaping: panes.reduce((bad, p, i) => {
|
||||
const pr = p.getBoundingClientRect();
|
||||
['.feed-fav-btn', '.feed-pip-btn', '.feed-format-btn', '.feed-pane-tools']
|
||||
.forEach((sel) => {
|
||||
const el = p.querySelector(sel);
|
||||
if (!el || el.hidden) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top < pr.top - 1 || r.bottom > pr.bottom + 1 ||
|
||||
r.left < pr.left - 1 || r.right > pr.right + 1) bad.push(i + sel);
|
||||
});
|
||||
return bad;
|
||||
}, []),
|
||||
within_slide: panes.every(p => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1;
|
||||
}),
|
||||
};
|
||||
}"""
|
||||
|
||||
PRELOAD = """() => {
|
||||
const active = App.state.feedActiveIndex;
|
||||
const rows = {};
|
||||
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
rows[Number(slide.dataset.step) - active] = panes.map((p) => {
|
||||
const v = p.querySelector('.feed-video');
|
||||
return !!(v && (v.getAttribute('src') || v._hlsPlayer));
|
||||
});
|
||||
});
|
||||
return { per: App.feed.paneCount(), next: rows[1] || null, current: rows[0] || null };
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def split(page, selector):
|
||||
page.click(".feed-slide.is-active " + selector)
|
||||
page.wait_for_timeout(4000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
open_reels(page)
|
||||
|
||||
print("\nsingle panel")
|
||||
one = page.evaluate(LAYOUT)
|
||||
c.ok("reels opens with one panel", one and one["count"] == 1, str(one))
|
||||
|
||||
print("\nsplit right, then split the new panel below")
|
||||
split(page, ".feed-pane .feed-pane-split-right")
|
||||
split(page, ".feed-pane:last-of-type .feed-pane-split-down")
|
||||
three = page.evaluate(LAYOUT)
|
||||
c.ok("three panels after two splits", three["count"] == 3, str(three["count"]))
|
||||
c.ok("paneCount agrees with the DOM", three["reported"] == three["count"])
|
||||
c.ok("every panel shows a different video",
|
||||
len(set(three["videos"])) == len(three["videos"]), str(three["videos"]))
|
||||
c.ok("panels fit inside the slide", three["within_slide"])
|
||||
c.ok("no control escapes its panel", not three["escaping"], str(three["escaping"]))
|
||||
|
||||
print("\npreload")
|
||||
pre = page.evaluate(PRELOAD)
|
||||
c.ok("the next step exists", pre["next"] is not None)
|
||||
c.ok("every panel of the current step is loaded", pre["current"] and all(pre["current"]),
|
||||
str(pre["current"]))
|
||||
# The point of the exercise: nobody should swipe into an empty panel.
|
||||
c.ok("every panel has its next video preloaded",
|
||||
pre["next"] is not None and all(pre["next"]) and len(pre["next"]) == pre["per"],
|
||||
str(pre["next"]))
|
||||
|
||||
print("\none swipe advances every panel")
|
||||
before = page.evaluate(LAYOUT)["videos"]
|
||||
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
page.wait_for_timeout(3500)
|
||||
after = page.evaluate(LAYOUT)["videos"]
|
||||
c.ok("all panels moved on", all(v not in before for v in after if v),
|
||||
f"{before} -> {after}")
|
||||
c.ok("still preloaded after the swipe",
|
||||
all(page.evaluate(PRELOAD)["next"] or [False]))
|
||||
|
||||
print("\nper-panel audio")
|
||||
page.click(".feed-slide.is-active .feed-pane:first-of-type .feed-pane-mute")
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => App.feed.renderSlides()") # re-activate the step
|
||||
page.wait_for_timeout(1500)
|
||||
muted = page.evaluate("""() => Array.from(
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-pane .feed-video')
|
||||
).map(v => v.muted)""")
|
||||
c.ok("only the unmuted panel has sound", muted and muted[0] is False
|
||||
and all(muted[1:]), str(muted))
|
||||
|
||||
print("\nclose a panel")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pane-close")
|
||||
page.wait_for_timeout(3500)
|
||||
closed = page.evaluate(LAYOUT)
|
||||
c.ok("closing collapses the split", closed["count"] == 2, str(closed["count"]))
|
||||
c.ok("survivors still fit the slide", closed["within_slide"])
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
208
tests/smoke_rotate.py
Normal file
208
tests/smoke_rotate.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turning the phone keeps the same video on screen.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_rotate.py
|
||||
|
||||
Both the status and the listing are fixtures, so the reel is long enough to
|
||||
skip forward in and the test never waits on a real source.
|
||||
|
||||
A rotation is not one event: the browser fires `resize` several times as its
|
||||
chrome and the viewport settle, and the scroll-snap container re-anchors on
|
||||
pixel positions measured against the old height somewhere in the middle of
|
||||
that. The test replays exactly that -- a second `resize` and then a stale
|
||||
scroll position -- and asks that the reel still be on the video it was on.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# A real (tiny, navy, silent) clip: the feed drops a video whose source fails,
|
||||
# and a reel that empties itself has no scroll positions to get wrong.
|
||||
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
PORTRAIT = {"width": 430, "height": 930}
|
||||
LANDSCAPE = {"width": 930, "height": 430}
|
||||
|
||||
STATUS = {
|
||||
"online": True,
|
||||
"channels": [{
|
||||
"id": CHANNEL,
|
||||
"name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.",
|
||||
"options": [],
|
||||
}],
|
||||
}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
WHERE = """() => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const active = document.querySelector('.feed-slide.is-active');
|
||||
const h = scroller.clientHeight;
|
||||
return {
|
||||
index: App.state.feedActiveIndex,
|
||||
videoId: App.state.feedActiveVideoId,
|
||||
scrollTop: scroller.scrollTop,
|
||||
expected: App.state.feedActiveIndex * h,
|
||||
height: h,
|
||||
// Where the active slide actually sits: the one thing the reader sees.
|
||||
activeTop: active ? Math.round(active.getBoundingClientRect().top) : null,
|
||||
activeId: active ? active.dataset.step : null,
|
||||
// Every rendered slide must be exactly a scroller tall, or
|
||||
// `index * clientHeight` stops addressing the slide it names.
|
||||
offHeight: Array.from(document.querySelectorAll('.feed-slide'))
|
||||
.map((s) => s.getBoundingClientRect().height)
|
||||
.filter((sh) => Math.abs(sh - h) > 1)
|
||||
};
|
||||
}"""
|
||||
|
||||
# A rotation as the browser actually delivers it: another resize while things
|
||||
# are still moving, then the container's own re-anchor on a position measured
|
||||
# against the height from before.
|
||||
ROTATION_NOISE = """(oldHeight) => new Promise((resolve) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const index = App.state.feedActiveIndex;
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 60);
|
||||
setTimeout(() => {
|
||||
scroller.scrollTop = index * oldHeight;
|
||||
resolve(index * oldHeight);
|
||||
}, 140);
|
||||
})"""
|
||||
|
||||
GO = """(step) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
scroller.scrollTop = step * scroller.clientHeight;
|
||||
return App.state.feedActiveIndex;
|
||||
}"""
|
||||
|
||||
|
||||
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 swipe_to(page, step):
|
||||
"""One step at a time, the way a thumb gets there: only the slides around
|
||||
the active one exist, so the scroll range doesn't reach step 7 in one go."""
|
||||
for n in range(1, step + 1):
|
||||
page.evaluate(GO, n)
|
||||
page.wait_for_timeout(250)
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def items():
|
||||
return [{"id": f"vid-{i}", "title": f"clip {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
|
||||
|
||||
|
||||
def rotate(page, c, size, label):
|
||||
before = page.evaluate(WHERE)
|
||||
page.set_viewport_size(size)
|
||||
stale = page.evaluate(ROTATION_NOISE, before["height"])
|
||||
page.wait_for_timeout(1400)
|
||||
after = page.evaluate(WHERE)
|
||||
print(f" (was step {before['index']} at {before['scrollTop']}px of {before['height']};"
|
||||
f" the stale re-anchor asked for {stale}px)")
|
||||
c.ok(f"{label}: the same video is still the one playing",
|
||||
after["videoId"] == before["videoId"], f"{before['videoId']} -> {after['videoId']}")
|
||||
c.ok(f"{label}: and the same step",
|
||||
after["index"] == before["index"], f"{before['index']} -> {after['index']}")
|
||||
c.ok(f"{label}: the scroll position is the one that step sits at",
|
||||
abs(after["scrollTop"] - after["expected"]) <= 1, str(after))
|
||||
c.ok(f"{label}: so the step fills the screen rather than straddling two",
|
||||
after["activeTop"] == 0, str(after["activeTop"]))
|
||||
c.ok(f"{label}: every slide is exactly a screenful",
|
||||
after["offHeight"] == [], str(after["offHeight"]))
|
||||
return after
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
|
||||
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=PORTRAIT)
|
||||
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}})))
|
||||
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
|
||||
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
|
||||
|
||||
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)
|
||||
page.wait_for_timeout(500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
print("\nseven videos into the reel")
|
||||
swipe_to(page, 7)
|
||||
start = page.evaluate(WHERE)
|
||||
c.ok("that is where the reel is", start["index"] == 7, str(start))
|
||||
c.ok("and the slides are a screenful each to begin with",
|
||||
start["offHeight"] == [], str(start["offHeight"]))
|
||||
|
||||
print("\nturning it sideways")
|
||||
land = rotate(page, c, LANDSCAPE, "landscape")
|
||||
c.ok("the slides took the new height", land["height"] == 430, str(land["height"]))
|
||||
|
||||
print("\nand back upright")
|
||||
port = rotate(page, c, PORTRAIT, "portrait")
|
||||
c.ok("the slides took the height back", port["height"] == 930, str(port["height"]))
|
||||
|
||||
print("\nswiping still works once it has settled")
|
||||
page.evaluate(GO, port["index"] + 1)
|
||||
page.wait_for_timeout(700)
|
||||
moved = page.evaluate(WHERE)
|
||||
c.ok("a real swipe after a rotation moves the reel on",
|
||||
moved["index"] == port["index"] + 1, str(moved["index"]))
|
||||
|
||||
print("\nrotating twice in a row, as a wrist does")
|
||||
page.set_viewport_size(LANDSCAPE)
|
||||
page.wait_for_timeout(90)
|
||||
page.set_viewport_size(PORTRAIT)
|
||||
page.wait_for_timeout(1500)
|
||||
twice = page.evaluate(WHERE)
|
||||
c.ok("the reel is where it was left", twice["index"] == moved["index"] and
|
||||
twice["videoId"] == moved["videoId"], str(twice))
|
||||
c.ok("and sitting on its snap point",
|
||||
abs(twice["scrollTop"] - twice["expected"]) <= 1, str(twice))
|
||||
|
||||
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())
|
||||
240
tests/smoke_rotate_smooth.py
Normal file
240
tests/smoke_rotate_smooth.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turning the phone moves the picture once, not a dozen times.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_rotate_smooth.py
|
||||
|
||||
Its sibling smoke_rotate.py asks that a turn keep the same video. This one
|
||||
asks what the turn looks like while it happens.
|
||||
|
||||
A turn is not one shape change: the viewport passes through every shape
|
||||
between the one it left and the one it lands in, and each one re-letterboxes
|
||||
the picture somewhere else. Painted, that run of jumps is the stutter. So the
|
||||
slides are held at the size the turn is heading for from its first frame: the
|
||||
picture steps straight into the position and size it will keep, and stays
|
||||
there -- no fade, no intermediate shapes, nothing dimmed.
|
||||
|
||||
A viewport that only changes height is the browser's chrome sliding away, not
|
||||
a wrist, and must not be pinned: every scroll on a phone does that.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
PORTRAIT = {"width": 430, "height": 930}
|
||||
LANDSCAPE = {"width": 930, "height": 430}
|
||||
# The shapes a viewport goes through on its way round.
|
||||
TURN = [{"width": 560, "height": 800}, {"width": 700, "height": 660},
|
||||
{"width": 830, "height": 520}, LANDSCAPE]
|
||||
# The chrome sliding away: height only, and nothing announced it.
|
||||
CHROME = {"width": 430, "height": 830}
|
||||
|
||||
STATUS = {"online": True, "channels": [{
|
||||
"id": CHANNEL, "name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.", "options": []}]}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# Samples every frame: how much of the reel is on screen, what shape the
|
||||
# picture has, and whether it is still where it belongs.
|
||||
WATCH = """() => {
|
||||
window.__seen = [];
|
||||
window.__watch = true;
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const tick = function() {
|
||||
const active = document.querySelector('.feed-slide.is-active');
|
||||
const video = active ? active.querySelector('.feed-video') : null;
|
||||
const box = video ? video.getBoundingClientRect() : null;
|
||||
window.__seen.push({
|
||||
opacity: Number(getComputedStyle(scroller).opacity),
|
||||
pinned: document.getElementById('feed-view').classList.contains('is-turning'),
|
||||
shape: box ? [Math.round(box.width), Math.round(box.height)] : null,
|
||||
top: active ? Math.round(active.getBoundingClientRect().top) : null,
|
||||
paused: video ? video.paused : null
|
||||
});
|
||||
if (window.__watch) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}"""
|
||||
|
||||
STOP = "() => { window.__watch = false; return window.__seen; }"
|
||||
|
||||
REST = """() => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const style = getComputedStyle(scroller);
|
||||
return {
|
||||
opacity: Number(style.opacity),
|
||||
snap: style.scrollSnapType,
|
||||
overflow: style.overflowY,
|
||||
shape: (function() {
|
||||
const box = document.querySelector('.feed-slide.is-active .feed-video');
|
||||
const rect = box ? box.getBoundingClientRect() : null;
|
||||
return rect ? [Math.round(rect.width), Math.round(rect.height)] : null;
|
||||
})(),
|
||||
turning: document.getElementById('feed-view').classList.contains('is-turning'),
|
||||
videoId: App.state.feedActiveVideoId,
|
||||
index: App.state.feedActiveIndex,
|
||||
scrollTop: Math.round(scroller.scrollTop),
|
||||
expected: App.state.feedActiveIndex * scroller.clientHeight
|
||||
};
|
||||
}"""
|
||||
|
||||
GO = """(step) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
scroller.scrollTop = step * scroller.clientHeight;
|
||||
}"""
|
||||
|
||||
# The container's own re-anchor, on a position measured against the height
|
||||
# from before the turn, arriving while the new one is already in place.
|
||||
STALE = """(oldHeight) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
scroller.scrollTop = App.state.feedActiveIndex * oldHeight;
|
||||
}"""
|
||||
|
||||
|
||||
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():
|
||||
return [{"id": f"vid-{i}", "title": f"clip {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
|
||||
|
||||
|
||||
def shape_changes(seen):
|
||||
"""The frames where the picture changed shape, with how lit they were."""
|
||||
out = []
|
||||
for a, b in zip(seen, seen[1:]):
|
||||
if a["shape"] and b["shape"] and a["shape"] != b["shape"]:
|
||||
out.append({"from": a["shape"], "to": b["shape"], "opacity": b["opacity"]})
|
||||
return out
|
||||
|
||||
|
||||
def do_turn(page, sizes, old_height, announced=True):
|
||||
page.evaluate(WATCH)
|
||||
page.wait_for_timeout(120)
|
||||
if announced:
|
||||
page.evaluate("() => window.dispatchEvent(new Event('orientationchange'))")
|
||||
for i, size in enumerate(sizes):
|
||||
page.set_viewport_size(size)
|
||||
if i == 1:
|
||||
page.evaluate(STALE, old_height)
|
||||
page.wait_for_timeout(70)
|
||||
page.wait_for_timeout(1400)
|
||||
return page.evaluate(STOP)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
|
||||
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=PORTRAIT)
|
||||
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}})))
|
||||
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
|
||||
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
|
||||
|
||||
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)
|
||||
page.wait_for_timeout(500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(800)
|
||||
for step in (1, 2, 3):
|
||||
page.evaluate(GO, step)
|
||||
page.wait_for_timeout(250)
|
||||
page.wait_for_timeout(800)
|
||||
before = page.evaluate(REST)
|
||||
|
||||
print("\nturning it sideways, one shape at a time")
|
||||
seen = do_turn(page, TURN, PORTRAIT["height"])
|
||||
changes = shape_changes(seen)
|
||||
rest = page.evaluate(REST)
|
||||
c.ok("the picture moved once, not through every shape on the way",
|
||||
len(changes) == 1, str(changes))
|
||||
c.ok("and the one step it took was straight into the size it keeps",
|
||||
bool(changes) and changes[0]["to"] == rest["shape"],
|
||||
f"{changes} -> {rest['shape']}")
|
||||
c.ok("nothing was dimmed to cover it",
|
||||
all(s["opacity"] == 1 for s in seen),
|
||||
str(min(s["opacity"] for s in seen)))
|
||||
c.ok("the reel stayed pinned to its step the whole way",
|
||||
all(s["top"] in (None, 0) for s in seen),
|
||||
str(sorted({s["top"] for s in seen})))
|
||||
c.ok("the slides were held only for the turn, not after it",
|
||||
0 < len([s for s in seen if s["pinned"]]) < len(seen) * 0.75,
|
||||
f"{len([s for s in seen if s['pinned']])} of {len(seen)} frames")
|
||||
c.ok("the clip never paused for it",
|
||||
not any(s["paused"] for s in seen), "")
|
||||
|
||||
c.ok("and the feed is no longer marked as turning", not rest["turning"], str(rest))
|
||||
c.ok("snapping is back on", "y" in rest["snap"], str(rest["snap"]))
|
||||
c.ok("and so is scrolling", rest["overflow"] == "scroll", str(rest["overflow"]))
|
||||
c.ok("on the same video it was on",
|
||||
rest["videoId"] == before["videoId"],
|
||||
f"{before['videoId']} -> {rest['videoId']}")
|
||||
c.ok("sitting on its snap point",
|
||||
abs(rest["scrollTop"] - rest["expected"]) <= 1, str(rest))
|
||||
|
||||
print("\nand back upright")
|
||||
seen = do_turn(page, list(reversed(TURN[:-1])) + [PORTRAIT], LANDSCAPE["height"])
|
||||
changes = shape_changes(seen)
|
||||
back = page.evaluate(REST)
|
||||
c.ok("one step on the way back too", len(changes) == 1, str(changes))
|
||||
c.ok("into the size it keeps",
|
||||
bool(changes) and changes[0]["to"] == back["shape"],
|
||||
f"{changes} -> {back['shape']}")
|
||||
c.ok("still the same video", back["videoId"] == before["videoId"], str(back))
|
||||
|
||||
print("\nthe browser's chrome sliding away is not a turn")
|
||||
seen = do_turn(page, [CHROME], PORTRAIT["height"], announced=False)
|
||||
c.ok("the slides were never pinned for it",
|
||||
not any(s["pinned"] for s in seen), "")
|
||||
page.set_viewport_size(PORTRAIT)
|
||||
page.wait_for_timeout(1200)
|
||||
chrome = page.evaluate(REST)
|
||||
c.ok("and it is still on the same video",
|
||||
chrome["videoId"] == before["videoId"], str(chrome))
|
||||
|
||||
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())
|
||||
416
tests/smoke_saved_searches.py
Normal file
416
tests/smoke_saved_searches.py
Normal file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A whole search -- source, channel, filters, query -- can be kept and come back to.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_saved_searches.py
|
||||
|
||||
Both the status and the listing are fixtures, so the test owns what "new"
|
||||
means: it changes the ids the listing hands back and then asks the client how
|
||||
many of them it has never shown.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
|
||||
STATUS = {
|
||||
"online": True,
|
||||
"channels": [{
|
||||
"id": CHANNEL,
|
||||
"name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.",
|
||||
"options": [{
|
||||
"id": "sort",
|
||||
"title": "Sort",
|
||||
"options": [
|
||||
{"id": "latest", "title": "Latest"},
|
||||
{"id": "views", "title": "Most viewed"},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.removeItem('searchHistory');
|
||||
localStorage.removeItem('savedSearches');
|
||||
}"""
|
||||
|
||||
SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')"""
|
||||
|
||||
BADGE = """() => {
|
||||
const badge = document.getElementById('saved-searches-badge');
|
||||
return { hidden: badge.hidden, text: badge.textContent };
|
||||
}"""
|
||||
|
||||
ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
|
||||
name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '',
|
||||
count: row.querySelector('.saved-count').textContent,
|
||||
quiet: row.querySelector('.saved-count').classList.contains('is-quiet'),
|
||||
meta: row.querySelector('.saved-row-meta').textContent,
|
||||
stamp: row.querySelector('.saved-row-stamp').textContent
|
||||
}))"""
|
||||
|
||||
MARK_ROWS = """() => {
|
||||
// A mark on the elements themselves: a rebuilt row cannot carry it.
|
||||
const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row'));
|
||||
rows.forEach((row, i) => { row.dataset.probe = `p${i}`; });
|
||||
return rows.length;
|
||||
}"""
|
||||
|
||||
PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
|
||||
probe: row.dataset.probe || '',
|
||||
count: row.querySelector('.saved-count').textContent,
|
||||
fresh: row.classList.contains('is-new'),
|
||||
stamp: row.querySelector('.saved-row-stamp').textContent
|
||||
}))"""
|
||||
|
||||
READ_BTN = """() => {
|
||||
const button = document.getElementById('saved-read-btn');
|
||||
return { disabled: button.disabled, text: button.textContent };
|
||||
}"""
|
||||
|
||||
IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
|
||||
PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
|
||||
SESSION = """() => {
|
||||
const s = JSON.parse(localStorage.getItem('session'));
|
||||
return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null };
|
||||
}"""
|
||||
|
||||
|
||||
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()
|
||||
asked = []
|
||||
# Which ids the listing hands back. Shifting this window is how the test
|
||||
# says "three new videos were uploaded".
|
||||
offset = {"at": 0, "fail": False}
|
||||
# How many videos a page comes back with, whatever was asked for: real
|
||||
# sources cut pages their own way and send what they send.
|
||||
size = {"n": 12}
|
||||
|
||||
def items(query):
|
||||
start = offset["at"]
|
||||
return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||
"duration": 1, "isLive": False, "tags": []}
|
||||
for i in range(start, start + size["n"])]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--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)))
|
||||
|
||||
def status(route):
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS))
|
||||
|
||||
def listing(route):
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
asked.append(body)
|
||||
if offset["fail"]:
|
||||
route.fulfill(status=500, content_type="application/json", body="{}")
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(
|
||||
{"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}}))
|
||||
|
||||
page.route("**/api/status", status)
|
||||
page.route("**/api/videos", listing)
|
||||
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)
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
print("\nthe feature is there at all")
|
||||
c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function')
|
||||
c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True)
|
||||
c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False)
|
||||
|
||||
print("\nkeeping the search that is on screen")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "sauna", delay=30)
|
||||
page.wait_for_timeout(900)
|
||||
page.click("#search-save-btn")
|
||||
page.wait_for_timeout(200)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("one search was kept", len(saved) == 1, str(saved))
|
||||
entry = saved[0] if saved else {}
|
||||
c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query")))
|
||||
c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId")))
|
||||
c.ok("the source", entry.get("server") == SERVER, str(entry.get("server")))
|
||||
c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options")))
|
||||
c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name")))
|
||||
c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or [])))
|
||||
c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True)
|
||||
c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True)
|
||||
|
||||
print("\nthe bookmark is about this search, not just this channel")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "pool", delay=30)
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False)
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "sauna", delay=30)
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True)
|
||||
|
||||
print("\nand a filter is part of it too")
|
||||
page.evaluate("""() => {
|
||||
const s = JSON.parse(localStorage.getItem('session'));
|
||||
s.options.sort = { id: 'views', title: 'Most viewed' };
|
||||
localStorage.setItem('session', JSON.stringify(s));
|
||||
App.ui.renderMenu();
|
||||
}""")
|
||||
page.wait_for_timeout(200)
|
||||
c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False)
|
||||
|
||||
print("\nthree videos turn up while nobody is looking")
|
||||
offset["at"] = 3
|
||||
asked.clear()
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(900)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("the check asked for the saved search, not what is on screen",
|
||||
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
|
||||
c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount")))
|
||||
badge = page.evaluate(BADGE)
|
||||
c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge))
|
||||
|
||||
print("\na busy day")
|
||||
offset["at"] = 40
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(900)
|
||||
saved = page.evaluate(SAVED)
|
||||
badge = page.evaluate(BADGE)
|
||||
c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount")))
|
||||
c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge))
|
||||
|
||||
print("\nonce a day, not once a page view")
|
||||
asked.clear()
|
||||
page.evaluate("() => App.savedSearches.check()")
|
||||
page.wait_for_timeout(500)
|
||||
c.ok("a search checked a moment ago is left alone", asked == [], str(asked))
|
||||
page.evaluate("""() => {
|
||||
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||
all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000;
|
||||
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||
}""")
|
||||
page.evaluate("() => App.savedSearches.check()")
|
||||
page.wait_for_timeout(900)
|
||||
c.ok("one last checked two days ago is asked about",
|
||||
any(b.get("query") == "sauna" for b in asked), str(asked))
|
||||
|
||||
print("\nthe list")
|
||||
page.click("#saved-searches-btn")
|
||||
page.wait_for_timeout(300)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("it opens", page.evaluate(PANEL_OPEN) is True)
|
||||
c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows))
|
||||
c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0]))
|
||||
c.ok("and saying where it points",
|
||||
"Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"],
|
||||
rows[0]["meta"])
|
||||
|
||||
print("\ntapping it puts the whole search back")
|
||||
asked.clear()
|
||||
page.click("#saved-panel .saved-row-main")
|
||||
page.wait_for_timeout(1200)
|
||||
c.ok("the list closes", page.evaluate(PANEL_OPEN) is False)
|
||||
c.ok("the words are back in the box",
|
||||
page.evaluate("() => document.getElementById('search-input').value") == "sauna")
|
||||
session = page.evaluate(SESSION)
|
||||
c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session))
|
||||
c.ok("and that is what was fetched",
|
||||
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("opening it is viewing it, so the count goes out",
|
||||
saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0]))
|
||||
c.ok("and what it showed is the new mark",
|
||||
"vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3]))
|
||||
c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True)
|
||||
|
||||
print("\nnothing new, said quietly")
|
||||
page.click("#saved-searches-btn")
|
||||
page.wait_for_timeout(300)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("no number where there is no news",
|
||||
rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0]))
|
||||
|
||||
print("\nrenaming one")
|
||||
page.click("#saved-panel .saved-tool[data-act='rename']")
|
||||
page.wait_for_timeout(250)
|
||||
page.fill("#saved-panel .saved-rename", "Sauna, newest first")
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(300)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name")))
|
||||
c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first")
|
||||
|
||||
print("\nsaving a second one from the list itself")
|
||||
page.evaluate("() => App.ui.savedSearches.close()")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "steam", delay=30)
|
||||
page.wait_for_timeout(900)
|
||||
page.click("#saved-searches-btn")
|
||||
page.wait_for_timeout(250)
|
||||
page.click("#saved-add-btn")
|
||||
page.wait_for_timeout(300)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved]))
|
||||
c.ok("the new one is first and named after its query",
|
||||
saved[0].get("query") == "steam", str(saved[0].get("name")))
|
||||
c.ok("and it opens ready to be named",
|
||||
page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')"))
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
print("\nremoving one asks first")
|
||||
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
|
||||
page.wait_for_timeout(250)
|
||||
c.ok("the row asks", page.evaluate(
|
||||
"() => !!document.querySelector('#saved-panel .saved-confirm')"))
|
||||
page.click("#saved-panel .saved-confirm-btn:not(.is-danger)")
|
||||
page.wait_for_timeout(250)
|
||||
c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2)
|
||||
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
|
||||
page.wait_for_timeout(250)
|
||||
page.click("#saved-panel .saved-confirm-btn.is-danger")
|
||||
page.wait_for_timeout(250)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved))
|
||||
|
||||
print("\na source that does not answer")
|
||||
page.evaluate("""() => {
|
||||
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||
all[0].newCount = 0;
|
||||
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||
}""")
|
||||
offset["fail"] = True
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(1200)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("is not a search with nothing new in it",
|
||||
saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0]))
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
|
||||
offset["fail"] = False
|
||||
|
||||
print("\nacross a reload")
|
||||
page.evaluate("""() => {
|
||||
const all = JSON.parse(localStorage.getItem('savedSearches'));
|
||||
all[0].newCount = 4;
|
||||
all[0].unreachable = false;
|
||||
all[0].lastCheckedAt = Date.now();
|
||||
localStorage.setItem('savedSearches', JSON.stringify(all));
|
||||
}""")
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.wait_for_timeout(600)
|
||||
badge = page.evaluate(BADGE)
|
||||
c.ok("the count is on screen straight from storage",
|
||||
badge["hidden"] is False and badge["text"] == "4", str(badge))
|
||||
c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
|
||||
|
||||
print("\na source that sends more than the page it was asked for")
|
||||
# sxyprn answers a page of 12 with 30. Everything past the twelfth was
|
||||
# never shown to the reader and was never new either -- it sat on the
|
||||
# same page all along -- so none of it may end up on the badge.
|
||||
size["n"] = 30
|
||||
page.evaluate("""async () => {
|
||||
App.videos.resetGrid();
|
||||
await App.videos.loadVideos();
|
||||
App.savedSearches.markViewed(App.savedSearches.getAll()[0].id);
|
||||
}""")
|
||||
page.wait_for_timeout(800)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("the mark holds the whole page the source sent, not the one asked for",
|
||||
len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or [])))
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(900)
|
||||
saved = page.evaluate(SAVED)
|
||||
badge = page.evaluate(BADGE)
|
||||
c.ok("the same page, checked again, is not news",
|
||||
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
|
||||
c.ok("so the badge stays out instead of sticking on a number",
|
||||
badge["hidden"] is True, str(badge))
|
||||
c.ok("and nothing is counted anywhere else either",
|
||||
page.evaluate("() => App.savedSearches.totalNew()") == 0)
|
||||
|
||||
print("\nnews on such a source still counts")
|
||||
offset["at"] = offset["at"] + 5
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(900)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount")))
|
||||
|
||||
print("\nmarking the whole list read")
|
||||
page.click("#saved-searches-btn")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("the button is offered while something is new",
|
||||
page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN)))
|
||||
asked.clear()
|
||||
page.click("#saved-read-btn")
|
||||
page.wait_for_timeout(1400)
|
||||
saved = page.evaluate(SAVED)
|
||||
badge = page.evaluate(BADGE)
|
||||
c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved))
|
||||
c.ok("and the header count with them", badge["hidden"] is True, str(badge))
|
||||
c.ok("the mark moves on to what the source lists now, so it stays out",
|
||||
f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and
|
||||
f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []),
|
||||
str((saved[0].get("seenIds") or [])[:3]))
|
||||
c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked))
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(900)
|
||||
saved = page.evaluate(SAVED)
|
||||
c.ok("a check straight after finds nothing new",
|
||||
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
|
||||
c.ok("and the button turns itself off with nothing left to clear",
|
||||
page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
|
||||
|
||||
print("\na check writes into the list without rebuilding it")
|
||||
c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1)
|
||||
offset["at"] = offset["at"] + 2
|
||||
page.evaluate("() => App.savedSearches.check({ force: true })")
|
||||
page.wait_for_timeout(1000)
|
||||
rows = page.evaluate(PROBES)
|
||||
c.ok("the row is the same element it was before the check",
|
||||
len(rows) == 1 and rows[0]["probe"] == "p0", str(rows))
|
||||
c.ok("with the count written into it where it stood",
|
||||
rows[0]["count"] == "2", str(rows[0]["count"]))
|
||||
c.ok("and the time of the check with it",
|
||||
"checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
|
||||
c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0]))
|
||||
|
||||
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())
|
||||
249
tests/smoke_search_history.py
Normal file
249
tests/smoke_search_history.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Searches are kept, and offered back under the box as you type.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_search_history.py
|
||||
|
||||
The listing is a fixture and records what it was asked for, so "the search
|
||||
actually ran" is something the test can see rather than infer.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.removeItem('searchHistory');
|
||||
}"""
|
||||
|
||||
HISTORY = """() => JSON.parse(localStorage.getItem('searchHistory') || '[]')"""
|
||||
|
||||
ROWS = """() => Array.from(document.querySelectorAll('#search-suggest .search-suggest-row'))
|
||||
.map((row) => row.dataset.query)"""
|
||||
|
||||
OPEN = """() => !document.getElementById('search-suggest').hidden"""
|
||||
|
||||
ACTIVE = """() => {
|
||||
const row = document.querySelector('#search-suggest .search-suggest-row.is-active');
|
||||
return row ? row.dataset.query : null;
|
||||
}"""
|
||||
|
||||
|
||||
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(query):
|
||||
return [{"id": f"fix-{query}-{i}", "title": f"{query or 'anything'} {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": "test",
|
||||
"duration": 1, "isLive": False, "tags": []} for i in range(12)]
|
||||
|
||||
|
||||
def wait_for_query(page, asked, query, ms=4000):
|
||||
"""The search may go out on the request already in flight rather than a new
|
||||
one of its own (the box searches as you type, so there usually is one), so
|
||||
what is waited for is the query reaching the server, not a fresh request."""
|
||||
for _ in range(ms // 100):
|
||||
if query in asked:
|
||||
return True
|
||||
page.wait_for_timeout(100)
|
||||
return query in asked
|
||||
|
||||
|
||||
def type_into(page, text):
|
||||
"""Types a query the way a reader would, a character at a time."""
|
||||
page.click("#search-input")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", text, delay=40)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
asked = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1100, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
# With `hold` on, listing requests are recorded but left unanswered, so
|
||||
# the test can act while one is still out.
|
||||
hold = {"on": False, "parked": []}
|
||||
|
||||
def answer(route, query):
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(
|
||||
{"items": items(query), "pageInfo": {"hasNextPage": False}}))
|
||||
|
||||
def listing(route):
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
query = body.get("query", "")
|
||||
asked.append(query)
|
||||
if hold["on"]:
|
||||
hold["parked"].append((route, query))
|
||||
return
|
||||
answer(route, query)
|
||||
|
||||
def release():
|
||||
hold["on"] = False
|
||||
while hold["parked"]:
|
||||
route, query = hold["parked"].pop(0)
|
||||
answer(route, query)
|
||||
|
||||
page.route("**/api/videos", listing)
|
||||
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)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print("\nsearching for something")
|
||||
type_into(page, "hotel")
|
||||
page.wait_for_timeout(1200)
|
||||
stored = page.evaluate(HISTORY)
|
||||
c.ok("the search was kept", "hotel" in stored, str(stored))
|
||||
c.ok("and the typing on the way to it was not",
|
||||
stored == ["hotel"], str(stored))
|
||||
c.ok("the search itself ran", asked and asked[-1] == "hotel", str(asked[-3:]))
|
||||
|
||||
print("\nand for something else, then reloading")
|
||||
type_into(page, "sauna")
|
||||
page.wait_for_timeout(1200)
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
stored = page.evaluate(HISTORY)
|
||||
c.ok("both searches survived the reload",
|
||||
stored == ["sauna", "hotel"], str(stored))
|
||||
|
||||
print("\nwith a history to draw on")
|
||||
page.evaluate("""() => localStorage.setItem('searchHistory', JSON.stringify([
|
||||
'hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room',
|
||||
'hot spring', 'cold plunge'
|
||||
]))""")
|
||||
page.click("#search-input")
|
||||
page.wait_for_timeout(300)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("the box offers the recent ones when empty", page.evaluate(OPEN) is True)
|
||||
c.ok("five of them, newest first",
|
||||
rows == ['hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room'], str(rows))
|
||||
|
||||
print("\ntyping narrows them down")
|
||||
page.type("#search-input", "hot", delay=40)
|
||||
page.wait_for_timeout(200)
|
||||
rows = page.evaluate(ROWS)
|
||||
print(f" (offered: {rows})")
|
||||
c.ok("only the ones it appears in are left",
|
||||
set(rows) == {'hot tub', 'hotel room', 'hot spring', 'shot in the dark'}, str(rows))
|
||||
c.ok("the ones it starts come first",
|
||||
rows[:3] == ['hot tub', 'hotel room', 'hot spring'], str(rows))
|
||||
c.ok("and the one it is buried in comes last",
|
||||
rows[-1] == 'shot in the dark', str(rows))
|
||||
c.ok("the part that matched is picked out",
|
||||
page.evaluate("""() => {
|
||||
const hit = document.querySelector('#search-suggest .search-suggest-hit');
|
||||
return !!hit && hit.textContent.toLowerCase() === 'hot';
|
||||
}"""))
|
||||
|
||||
print("\nwhat is already typed in full is not a suggestion")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "sauna", delay=20)
|
||||
page.wait_for_timeout(200)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("it offers itself back to nobody",
|
||||
'sauna' not in rows and page.evaluate(OPEN) is False, str(rows))
|
||||
|
||||
print("\ntaking one with the keyboard")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "hot", delay=20)
|
||||
page.wait_for_timeout(200)
|
||||
page.keyboard.press("ArrowDown")
|
||||
first = page.evaluate(ACTIVE)
|
||||
page.keyboard.press("ArrowDown")
|
||||
second = page.evaluate(ACTIVE)
|
||||
c.ok("the arrows walk the list", first == 'hot tub' and second == 'hotel room',
|
||||
f"{first} then {second}")
|
||||
asked.clear()
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("enter takes the one highlighted",
|
||||
page.evaluate("() => document.getElementById('search-input').value") == 'hotel room')
|
||||
c.ok("and searches for it", wait_for_query(page, asked, 'hotel room'), str(asked))
|
||||
c.ok("the list is put away", page.evaluate(OPEN) is False)
|
||||
|
||||
print("\ntaking one with the pointer")
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
page.type("#search-input", "st", delay=20)
|
||||
page.wait_for_timeout(250)
|
||||
asked.clear()
|
||||
rows = page.evaluate(ROWS)
|
||||
page.click(f"#search-suggest .search-suggest-row:nth-of-type(1)")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("the one clicked is what gets searched for",
|
||||
page.evaluate("() => document.getElementById('search-input').value") == rows[0]
|
||||
and wait_for_query(page, asked, rows[0]), f"{rows} then {asked}")
|
||||
|
||||
print("\nescape")
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
page.wait_for_timeout(250)
|
||||
c.ok("the list is up again", page.evaluate(OPEN) is True)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(150)
|
||||
c.ok("escape puts it away", page.evaluate(OPEN) is False)
|
||||
|
||||
print("\nchoosing one while the box's own search is still out")
|
||||
# The box searches as you type: choose a suggestion at the wrong moment
|
||||
# and the search behind it used to be dropped, leaving the results on
|
||||
# the query before it.
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
hold["on"] = True
|
||||
asked.clear()
|
||||
page.type("#search-input", "hot", delay=20)
|
||||
page.wait_for_timeout(700) # long enough for the typed search to go out
|
||||
c.ok("the typed search is on the wire", asked == ['hot'], str(asked))
|
||||
page.keyboard.press("ArrowDown")
|
||||
chosen = page.evaluate(ACTIVE)
|
||||
page.keyboard.press("Enter")
|
||||
landed = wait_for_query(page, asked, chosen, 3000)
|
||||
release()
|
||||
page.wait_for_timeout(800)
|
||||
c.ok(f"the chosen one ({chosen}) is searched for all the same", landed, str(asked))
|
||||
c.ok("and its results are what the grid ends up with",
|
||||
page.evaluate("() => (App.state.loadedVideos[0] || {}).id || ''")
|
||||
.startswith(f"fix-{chosen}"),
|
||||
page.evaluate("() => (App.state.loadedVideos[0] || {}).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())
|
||||
348
tests/smoke_split_reuse.py
Normal file
348
tests/smoke_split_reuse.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Splitting a panel keeps the video that is already playing, rather than
|
||||
fetching it again.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_split_reuse.py
|
||||
|
||||
The clip server counts what is asked of it, which is the whole point: a split
|
||||
renumbers every step and rebuilds the slides, and the question is whether the
|
||||
bytes already in hand survive that. The listing is a fixture for the same
|
||||
reason as everywhere else -- so the answer is about the feed, not a CDN.
|
||||
|
||||
Each panel's <video> is tagged, so a rebuilt panel is told apart from a reused
|
||||
one by the element rather than by what it is showing.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
PER_PAGE = 12
|
||||
PAGES = 8
|
||||
MEDIA_DELAY_MS = 300 # what a clip costs if it isn't already loading
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the panels have to
|
||||
# decode a frame from it. Same blob as smoke_open.py.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
def make_server(fetched):
|
||||
"""Serves the fixture clip, counting who asked for what."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
fetched[self.path] = fetched.get(self.path, 0) + 1
|
||||
time.sleep(MEDIA_DELAY_MS / 1000)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self):
|
||||
fetched[self.path] = fetched.get(self.path, 0) + 1
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
# Which videos were asked to resolve their formats. Prefilled formats make the
|
||||
# answer immediate, but the asking is what says when the feed goes looking.
|
||||
SPY = """() => {
|
||||
window.__resolved = [];
|
||||
const real = App.videos.ensureFormats;
|
||||
App.videos.ensureFormats = function(video) {
|
||||
if (video && video.id) window.__resolved.push(String(video.id));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
}"""
|
||||
|
||||
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
|
||||
step: Number(slide.dataset.step),
|
||||
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
|
||||
.map((pane) => pane.classList.contains('is-loaded')),
|
||||
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
|
||||
}))"""
|
||||
|
||||
WATCH = """() => {
|
||||
window.__t0 = performance.now();
|
||||
window.__started = {};
|
||||
document.addEventListener('playing', (e) => {
|
||||
const pane = e.target.closest && e.target.closest('.feed-pane');
|
||||
if (!pane) return;
|
||||
const id = pane.dataset.videoId;
|
||||
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
|
||||
}, true);
|
||||
}"""
|
||||
|
||||
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click(); // dispatched, so the pointer never wakes the HUD
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
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 make_items(page_no, media):
|
||||
"""One page of the listing, formats already resolved so nothing but the
|
||||
media itself stands between a panel and its first frame."""
|
||||
start = (page_no - 1) * PER_PAGE
|
||||
items = []
|
||||
for i in range(PER_PAGE):
|
||||
n = start + i
|
||||
url = f"{media}/v{n}.mp4"
|
||||
items.append({
|
||||
"id": f"fix-{n}",
|
||||
"title": f"Fixture {n}",
|
||||
"url": "https://example.test/watch/%d" % n,
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
# What a panel is holding: the element itself (tagged), where it has got to,
|
||||
# and whether it still has its source.
|
||||
MARK = """() => {
|
||||
document.querySelectorAll('.feed-pane').forEach((pane, i) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
if (video && !video.__tag) video.__tag = 'v' + i + '-' + pane.dataset.videoId;
|
||||
});
|
||||
}"""
|
||||
|
||||
PANELS = """() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-pane')).map((pane) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
return {
|
||||
id: pane.dataset.videoId,
|
||||
tag: video ? (video.__tag || null) : null,
|
||||
t: video ? Math.round(video.currentTime * 100) / 100 : null,
|
||||
paused: video ? video.paused : null,
|
||||
buffered: video && video.buffered.length ? Math.round(video.buffered.end(0) * 100) / 100 : 0,
|
||||
ready: video ? video.readyState : -1,
|
||||
loaded: pane.classList.contains('is-loaded'),
|
||||
};
|
||||
})"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click();
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
fetched = {}
|
||||
httpd = make_server(fetched)
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
parked = []
|
||||
|
||||
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=768",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 800, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
def answer():
|
||||
while parked:
|
||||
route = parked.pop(0)
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
page_no = int(body.get("page", 1) or 1)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"items": make_items(page_no, media) if page_no <= PAGES else [],
|
||||
"pageInfo": {"hasNextPage": page_no < PAGES},
|
||||
}))
|
||||
|
||||
def settle(ms):
|
||||
for _ in range(max(1, ms // 100)):
|
||||
page.wait_for_timeout(100)
|
||||
answer()
|
||||
|
||||
page.route("**/api/videos", lambda route: parked.append(route))
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
settle(1500)
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
settle(4000)
|
||||
|
||||
page.evaluate(MARK)
|
||||
before = page.evaluate(PANELS)
|
||||
watched = before[0]["id"]
|
||||
print(f" (watching {watched}, at {before[0]['t']}s)")
|
||||
c.ok("one panel to start with", len(before) == 1, str(before))
|
||||
c.ok("and it is playing", before[0]["paused"] is False, str(before[0]))
|
||||
asked_before = dict(fetched)
|
||||
|
||||
print("\nsplitting it in two")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane .feed-pane-split-right")
|
||||
settle(2500)
|
||||
after = page.evaluate(PANELS)
|
||||
kept = [pane for pane in after if pane["id"] == watched]
|
||||
print(f" (panels now: {[(x['id'], x['tag'], x['t']) for x in after]})")
|
||||
c.ok("there are two panels", len(after) == 2, str(after))
|
||||
c.ok("the video being watched is still one of them", len(kept) == 1, str(after))
|
||||
c.ok("in the same element it was playing in -- not a new one",
|
||||
kept and kept[0]["tag"] == before[0]["tag"], f"{kept and kept[0]['tag']} vs {before[0]['tag']}")
|
||||
# Not currentTime: the fixture clip is a second long and the feed loops
|
||||
# it, so where it has got to says nothing. What says it never reloaded
|
||||
# is that it still has decoded frames and a buffer.
|
||||
c.ok("with the frames it had already decoded, not back at nothing",
|
||||
kept and kept[0]["ready"] >= 2 and kept[0]["buffered"] > 0, str(kept))
|
||||
c.ok("and it is still playing", kept and kept[0]["paused"] is False, str(kept))
|
||||
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
watched_path = "/v0.mp4"
|
||||
c.ok("its bytes were not fetched a second time",
|
||||
watched_path not in again,
|
||||
f"asked for again: {sorted(again)}")
|
||||
|
||||
print("\nand splitting once more")
|
||||
page.evaluate(MARK) # tag first, then read: an untagged pane
|
||||
marks = page.evaluate(PANELS) # on both sides would compare None to None
|
||||
asked_before = dict(fetched)
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-down")
|
||||
settle(2500)
|
||||
three = page.evaluate(PANELS)
|
||||
held = {pane["id"]: pane for pane in three}
|
||||
c.ok("three panels now", len(three) == 3, str(three))
|
||||
expected = [m for m in marks if m["id"] in held]
|
||||
carried = [m for m in expected if held[m["id"]]["tag"] == m["tag"]]
|
||||
c.ok("both panels that were already playing carried their element over",
|
||||
expected and all(m["tag"] for m in expected) and len(carried) == len(expected),
|
||||
str(three))
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
c.ok("and neither was fetched again",
|
||||
not any(f"/v{i}.mp4" in again for i in (0, 1)), f"asked for again: {sorted(again)}")
|
||||
|
||||
print("\nclosing one of them")
|
||||
asked_before = dict(fetched)
|
||||
page.evaluate(MARK)
|
||||
marks = page.evaluate(PANELS)
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
|
||||
settle(2500)
|
||||
left = page.evaluate(PANELS)
|
||||
held = {pane["id"]: pane for pane in left}
|
||||
c.ok("two panels left", len(left) == 2, str(left))
|
||||
survivors = [m for m in marks if m["id"] in held]
|
||||
c.ok("the survivors kept their elements",
|
||||
survivors and all(m["tag"] and held[m["id"]]["tag"] == m["tag"] for m in survivors),
|
||||
str(left))
|
||||
c.ok("still playing", all(pane["paused"] is False for pane in left), str(left))
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
c.ok("and nothing that was already loaded was fetched again",
|
||||
not any(f"/v{i}.mp4" in again for i in (0, 1, 2)), f"asked for again: {sorted(again)}")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
httpd.shutdown()
|
||||
|
||||
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())
|
||||
333
tests/smoke_thumbnails.py
Normal file
333
tests/smoke_thumbnails.py
Normal file
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thumbnail smoke tests: junk URLs, and images that fail once.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_thumbnails.py
|
||||
|
||||
The listing and the image hosts are both served by this script rather than by a
|
||||
provider, because what's under test is what the client does with awkward data:
|
||||
|
||||
* a `thumb` that isn't a URL at all -- sxyprn's "latest" listing sends items
|
||||
whose thumb is the bare string "https:". Resolved against the page that is
|
||||
*our own* address, so the card used to race our own HTML as if it were a
|
||||
picture, pin our origin to the proxy for the rest of the session, and ask
|
||||
/api/image to fetch "https:" (a 400, every time).
|
||||
|
||||
* 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 = 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(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
STATE = """() => Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||
const img = card.querySelector('img');
|
||||
return {
|
||||
id: card.dataset.videoId,
|
||||
src: img ? img.getAttribute('src') || '' : '',
|
||||
loaded: img ? img.naturalWidth > 0 : false,
|
||||
};
|
||||
})"""
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 listing():
|
||||
"""Twelve ordinary items, one with the junk thumb, one that fails once."""
|
||||
items = []
|
||||
for i in range(12):
|
||||
items.append({
|
||||
"id": f"test:{i}",
|
||||
"title": f"Video {i}",
|
||||
"url": f"{CDN}/watch/{i}",
|
||||
"channel": "test",
|
||||
"duration": 60 + i,
|
||||
"thumb": f"{CDN}/thumb/{i}.png",
|
||||
"tags": [],
|
||||
})
|
||||
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",
|
||||
# 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})
|
||||
|
||||
def serve_listing(route):
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=json.dumps(listing()))
|
||||
|
||||
def serve_proxy(route):
|
||||
# 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
|
||||
# round, which is what used to leave the card empty for good.
|
||||
if flaky_hits["proxy"] == 1:
|
||||
route.fulfill(status=502, content_type="text/plain", body="nope")
|
||||
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:
|
||||
route.abort("connectionfailed")
|
||||
return
|
||||
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)
|
||||
|
||||
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)
|
||||
# Long enough for the host race (2.5s of patience) and the retry ladder
|
||||
# (~900ms for its second step) to have run their course.
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
cards = page.evaluate(STATE)
|
||||
by_id = {card["id"]: card for card in cards}
|
||||
|
||||
print("\na thumb that isn't a URL")
|
||||
junk = by_id.get("test:3")
|
||||
c.ok("the card is mounted", junk is not None)
|
||||
if junk:
|
||||
c.ok("it asks for nothing at all", junk["src"] == "",
|
||||
f"src={junk['src']!r}")
|
||||
c.ok("and nothing is sent to the image proxy for it",
|
||||
not [u for u in image_proxy_calls if "https%3A&" in u or u.endswith("url=https%3A")],
|
||||
str([u for u in image_proxy_calls if "https%3A" in u][:2]))
|
||||
|
||||
print("\nthe rest of the page is unaffected by it")
|
||||
# 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"]]))
|
||||
# The junk URL used to resolve to our own origin, whose race then failed
|
||||
# and pinned it to the proxy -- for everything, for the whole session.
|
||||
c.ok("our own origin is not pinned to the proxy",
|
||||
page.evaluate("() => App.videos.thumbnailUrl(location.origin + '/x.png')")
|
||||
== page.evaluate("() => location.origin + '/x.png'"))
|
||||
|
||||
print("\na thumbnail refused on both routes, once")
|
||||
flaky = by_id.get("test:7")
|
||||
c.ok("the card is mounted", flaky is not None)
|
||||
c.ok("both routes were refused once",
|
||||
flaky_hits["direct"] >= 1 and flaky_hits["proxy"] >= 1,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
c.ok("and it was asked for again after that",
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] > 2,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
if flaky:
|
||||
c.ok("so the card ends up showing a picture", flaky["loaded"],
|
||||
f"src={flaky['src']!r}")
|
||||
c.ok("and a dead thumbnail stops being asked for",
|
||||
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
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
188
tests/unit_direct_probe.js
Normal file
188
tests/unit_direct_probe.js
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
/* The direct-playability probe, tested without a browser.
|
||||
*
|
||||
* node tests/unit_direct_probe.js
|
||||
*
|
||||
* The probe decides whether the player may skip the proxy and let the browser
|
||||
* fetch a CDN URL itself. Getting that wrong is not a slow video but a wrong
|
||||
* one: eporner's CDN answers a browser-origin Referer with a 302 to a
|
||||
* one-minute "watch this at our site instead" clip, served 206 with
|
||||
* `Access-Control-Allow-Origin: *`, so "bytes arrived" was satisfied by a
|
||||
* stand-in and the player switched to it mid-video. What settles it is whether
|
||||
* the direct route hands back the same media the proxied one does, which is
|
||||
* two stubbed fetches to check -- no server, no Chromium, no network.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const noop = () => {};
|
||||
const element = () => ({
|
||||
style: { setProperty: noop, removeProperty: noop },
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
dataset: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
remove: noop,
|
||||
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
setAttribute: noop,
|
||||
removeAttribute: noop,
|
||||
getAttribute: () => null,
|
||||
cloneNode: element,
|
||||
content: { firstElementChild: { cloneNode: element } },
|
||||
children: [],
|
||||
childElementCount: 0,
|
||||
});
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
URL,
|
||||
Response,
|
||||
AbortController,
|
||||
Image: function () { return element(); },
|
||||
IntersectionObserver: function () {
|
||||
return { observe: noop, unobserve: noop, disconnect: noop };
|
||||
},
|
||||
requestAnimationFrame: noop,
|
||||
requestIdleCallback: noop,
|
||||
performance: { now: () => 0 },
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
};
|
||||
sandbox.addEventListener = noop;
|
||||
sandbox.removeEventListener = noop;
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.document = {
|
||||
getElementById: () => null,
|
||||
createElement: element,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
documentElement: element(),
|
||||
body: element(),
|
||||
head: element(),
|
||||
};
|
||||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
|
||||
sandbox.window.location = { href: 'http://localhost/' };
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
|
||||
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', 'videos.js'), 'utf8'),
|
||||
context, 'videos.js');
|
||||
|
||||
const { probeDirect } = sandbox.App.videos;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (label, cond, detail) => {
|
||||
if (!cond) failed++;
|
||||
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
|
||||
};
|
||||
|
||||
// Stands in for both routes. `direct` is what the CDN hands the browser,
|
||||
// `proxied` what /api/stream reports -- either given as a byte count, or null
|
||||
// for a response that states no length at all. Every call is recorded so the
|
||||
// test can check what was asked of whom.
|
||||
let calls = [];
|
||||
const stubFetch = function(direct, proxied) {
|
||||
calls = [];
|
||||
sandbox.fetch = async (url, opts) => {
|
||||
calls.push({ url: String(url), method: (opts && opts.method) || 'GET' });
|
||||
if (String(url).indexOf('/api/stream') === 0) {
|
||||
const headers = proxied === null ? {} : { 'Content-Length': String(proxied) };
|
||||
return new Response('', { status: 200, headers: headers });
|
||||
}
|
||||
const headers = direct === null ? {} : { 'Content-Length': String(direct) };
|
||||
return new Response(new Uint8Array(16), { status: 206, headers: headers });
|
||||
};
|
||||
};
|
||||
|
||||
// A fresh host each time: the probe caches its answer per host for the session,
|
||||
// which is the point of it, so reusing one would answer from the cache.
|
||||
let hostSeq = 0;
|
||||
const sourceOn = (host) => ({
|
||||
url: `https://${host}/v4/token/10299874-1080p.mp4`,
|
||||
referer: 'https://www.eporner.com/',
|
||||
headers: { Referer: 'https://www.eporner.com/' },
|
||||
});
|
||||
const freshSource = () => sourceOn(`cdn${++hostSeq}.example.com`);
|
||||
|
||||
const run = async () => {
|
||||
console.log('\nwhat the two routes say about the same URL');
|
||||
|
||||
stubFetch(486933813, 486933813);
|
||||
ok('the same length both ways is a shortcut', await probeDirect(freshSource()) === true);
|
||||
|
||||
// The bug: 642497 is static.eporner.com/na.mp4, the stand-in clip.
|
||||
stubFetch(642497, 486933813);
|
||||
ok('a shorter file than the proxy reports is a substitution',
|
||||
await probeDirect(freshSource()) === false);
|
||||
|
||||
// Not every mismatch is a decoy -- a longer one is just as wrong.
|
||||
stubFetch(486933813, 642497);
|
||||
ok('any disagreement about length is refused',
|
||||
await probeDirect(freshSource()) === false);
|
||||
|
||||
console.log('\nwhen a length cannot be had');
|
||||
|
||||
stubFetch(642497, null);
|
||||
ok('a proxy that states no length leaves the bytes test standing',
|
||||
await probeDirect(freshSource()) === true);
|
||||
|
||||
stubFetch(null, 486933813);
|
||||
ok('a direct route that states no length is not accused',
|
||||
await probeDirect(freshSource()) === true);
|
||||
|
||||
stubFetch(null, null);
|
||||
ok('neither stating one still plays direct',
|
||||
await probeDirect(freshSource()) === true);
|
||||
|
||||
console.log('\nhow the cross-check is addressed');
|
||||
|
||||
stubFetch(486933813, 486933813);
|
||||
const src = freshSource();
|
||||
await probeDirect(src);
|
||||
const head = calls.find((c) => c.url.indexOf('/api/stream') === 0);
|
||||
ok('the proxy is asked, by HEAD', !!head && head.method === 'HEAD',
|
||||
head ? head.method : 'never asked');
|
||||
ok('with the Referer the origin wants, not the page\'s',
|
||||
!!head && head.url.indexOf(`referer=${encodeURIComponent('https://www.eporner.com/')}`) !== -1,
|
||||
head ? head.url : '');
|
||||
ok('about the very URL being probed',
|
||||
!!head && head.url.indexOf(encodeURIComponent(src.url)) !== -1);
|
||||
|
||||
console.log('\nthe answer is kept per host');
|
||||
|
||||
stubFetch(486933813, 486933813);
|
||||
const repeat = sourceOn('cached.example.com');
|
||||
await probeDirect(repeat);
|
||||
const afterFirst = calls.length;
|
||||
await probeDirect(repeat);
|
||||
ok('a second probe of a known host asks nobody', calls.length === afterFirst,
|
||||
`${calls.length - afterFirst} extra call(s)`);
|
||||
|
||||
console.log('\na bare URL still works');
|
||||
|
||||
stubFetch(642497, 486933813);
|
||||
ok('a string is probed, and still cross-checked',
|
||||
await probeDirect('https://bare.example.com/v.mp4') === false);
|
||||
|
||||
stubFetch(486933813, 486933813);
|
||||
ok('nothing to probe is not playable', await probeDirect('') === false);
|
||||
ok('neither is a source without a URL', await probeDirect({ url: '' }) === false);
|
||||
};
|
||||
|
||||
run().then(() => {
|
||||
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
159
tests/unit_formats.js
Executable file
159
tests/unit_formats.js
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/* Format selection, tested without a browser.
|
||||
*
|
||||
* node tests/unit_formats.js
|
||||
*
|
||||
* Picking a rendition is pure: a list of formats in, one URL out. That makes it
|
||||
* the one part of playback that can be checked in a second, with no server, no
|
||||
* Chromium and no network -- which matters, because the height cap a split
|
||||
* reels panel applies is the difference between decoding four 1080p streams and
|
||||
* four 480p ones.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
// Enough of a browser for videos.js to finish loading. It builds a few
|
||||
// IntersectionObservers and reads matchMedia at module scope; nothing below
|
||||
// touches the DOM.
|
||||
const noop = () => {};
|
||||
const element = () => ({
|
||||
style: { setProperty: noop, removeProperty: noop },
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
dataset: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
remove: noop,
|
||||
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
setAttribute: noop,
|
||||
removeAttribute: noop,
|
||||
getAttribute: () => null,
|
||||
cloneNode: element,
|
||||
content: { firstElementChild: { cloneNode: element } },
|
||||
children: [],
|
||||
childElementCount: 0,
|
||||
});
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
URL,
|
||||
Image: function () { return element(); },
|
||||
IntersectionObserver: function () {
|
||||
return { observe: noop, unobserve: noop, disconnect: noop };
|
||||
},
|
||||
requestAnimationFrame: noop,
|
||||
requestIdleCallback: noop,
|
||||
performance: { now: () => 0 },
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
};
|
||||
sandbox.addEventListener = noop;
|
||||
sandbox.removeEventListener = noop;
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.document = {
|
||||
getElementById: () => null,
|
||||
createElement: element,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
documentElement: element(),
|
||||
body: element(),
|
||||
head: element(),
|
||||
};
|
||||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
|
||||
sandbox.window.location = { href: 'http://localhost/' };
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
const load = (file) => vm.runInContext(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', file), 'utf8'), context, file);
|
||||
|
||||
// videos.js reaches for these siblings when a card is built; none of the
|
||||
// functions under test do.
|
||||
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
|
||||
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
|
||||
load('videos.js');
|
||||
|
||||
const { rankFormats, resolveStreamSource, resolveStreamSources } = sandbox.App.videos;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (label, cond, detail) => {
|
||||
if (!cond) failed++;
|
||||
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ url: 'u240', height: 240, vcodec: 'avc1' },
|
||||
{ url: 'u480', height: 480, vcodec: 'avc1' },
|
||||
{ url: 'u720', height: 720, vcodec: 'avc1' },
|
||||
{ url: 'u1080', height: 1080, vcodec: 'avc1' },
|
||||
];
|
||||
const video = { id: 'v1', url: 'https://example.com/watch', meta: { formats: formats } };
|
||||
const heightOf = (src) => (formats.find((f) => f.url === src.url) || {}).height;
|
||||
|
||||
console.log('\nranking');
|
||||
ok('no ceiling takes the best', rankFormats(formats, null)[0].height === 1080);
|
||||
ok('a ceiling takes the best at or below it', rankFormats(formats, 720)[0].height === 720);
|
||||
ok('an exact ceiling is allowed', rankFormats(formats, 480)[0].height === 480);
|
||||
ok('below every rendition still returns one', rankFormats(formats, 100)[0].height === 240,
|
||||
String(rankFormats(formats, 100)[0].height));
|
||||
ok('everything stays reachable as fallback', rankFormats(formats, 480).length === formats.length);
|
||||
|
||||
console.log('\nthe cap a split panel applies');
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
ok('uncapped panel gets the best', heightOf(resolveStreamSource(video)) === 1080);
|
||||
ok('a quarter-screen panel gets a quarter-screen rendition',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
ok('the cap survives into the fallback order',
|
||||
heightOf(resolveStreamSources(video, { maxHeight: 480 })[0]) === 480);
|
||||
|
||||
console.log('\nthe cap and the quality preference are both ceilings');
|
||||
sandbox.App.storage.getPreferredQuality = () => '720';
|
||||
ok('preference alone caps at 720', heightOf(resolveStreamSource(video)) === 720);
|
||||
ok('the tighter of the two wins (panel)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
sandbox.App.storage.getPreferredQuality = () => '480';
|
||||
ok('the tighter of the two wins (preference)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 720 })) === 480);
|
||||
ok('a cap never raises the preference',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480);
|
||||
|
||||
console.log('\nranking by what it costs to decode');
|
||||
// Same picture, four ways of arriving at it.
|
||||
const mixed = [
|
||||
{ url: 'hls720', height: 720, vcodec: 'avc1', protocol: 'm3u8_native', fps: 30 },
|
||||
{ url: 'av1720', height: 720, vcodec: 'av01.0.05M.08', protocol: 'https', ext: 'mp4', fps: 30 },
|
||||
{ url: 'avc720', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 30, tbr: 900 },
|
||||
{ url: 'avc720p60', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 60, tbr: 2000 },
|
||||
];
|
||||
const mixedVideo = { id: 'v2', url: 'https://example.com/w2', meta: { formats: mixed } };
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
|
||||
const best = (opts) => resolveStreamSource(mixedVideo, opts).url;
|
||||
ok('without the flag, bitrate still wins', best({}) === 'avc720p60', best({}));
|
||||
ok('cheapest avoids HLS demuxing in JS', best({ cheapest: true }) !== 'hls720');
|
||||
ok('cheapest avoids software-decoded AV1', best({ cheapest: true }) !== 'av1720');
|
||||
ok('cheapest avoids 60fps', best({ cheapest: true }) !== 'avc720p60');
|
||||
ok('cheapest picks progressive H.264 at 30fps',
|
||||
best({ cheapest: true }) === 'avc720', best({ cheapest: true }));
|
||||
|
||||
// Cheapness must not override the size ceiling, or a split panel would get a
|
||||
// bigger picture than it can afford just because it is cheap per pixel.
|
||||
const tall = [
|
||||
{ url: 'hls480', height: 480, vcodec: 'avc1', protocol: 'm3u8_native' },
|
||||
{ url: 'mp4_1080', height: 1080, vcodec: 'avc1', protocol: 'https', ext: 'mp4' },
|
||||
];
|
||||
const tallVideo = { id: 'v3', url: 'https://example.com/w3', meta: { formats: tall } };
|
||||
ok('the height ceiling still comes first',
|
||||
resolveStreamSource(tallVideo, { maxHeight: 480, cheapest: true }).url === 'hls480');
|
||||
|
||||
ok('every format stays reachable as fallback',
|
||||
rankFormats(mixed, null, { cheapest: true }).length === mixed.length);
|
||||
|
||||
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user