doc 26 INC4: black band + 4K HDR slow transcode + v2 test + methodology audit

Two regressions slipped through INC1-3:

INC4a -- BLACK BAND behind every detail-page carousel
  Pre-existing 2026-05-08 home-page rule painted .emby-scroller {bg:#000
  !important} UNSCOPED. Hits every carousel inside .itemDetailPage incl
  admin-only More from Season N, More Like This. INC1-3 transparent-scope
  list missed .emby-scroller / .verticalSection / .padded-top-focusscale.
  Fixed by extending scope.

INC4b -- VIDEO 'BLACK SCREEN' on play
  Not actually black-screen. CPU-only nullstone cannot sustain real-time
  4K HEVC HDR tonemap+x264 transcode -- 0.5x realtime, ffmpeg takes ~6s
  per 3s segment. With user resume seeks adding restart overhead, total
  wait ~18s before browser readyState rises. User saw black, gave up.
  Fix: disable EnableTonemapping (R&M fake HDR per doc 21) + cap
  RemoteClientBitrateLimit=20Mbps on every user (1080p target, no 4K
  scale). Headless v2 test confirms HEVC + AV1 episodes now hit
  readyState=3/4 within wait window; 4K HDR R&M still slow (heaviest).

INC4 testing methodology audit -- bin/headless-test-v2.py
  v1 only logged in as guest and never clicked Play. v2 runs both admin
  and guest, walks 3 codec-tagged items per role (HEVC/AV1/H.264),
  clicks Play, captures <video> state, sweeps DOM for opaque bgs over
  backdrop layer. False positives: off-viewport #reactRoot + collapsed
  .mainDrawer (negative coords). Allowlist refinement TODO.

Open: 4K HDR sources still slow even post-fix. Real fix path = pre-
transcode masters to 1080p H.264 SDR via separate batch, OR migrate to
10.11.8 with vaapi/qsv driver fixed.
This commit is contained in:
s8n 2026-05-09 01:46:47 +01:00
parent 9b06bb48c6
commit 6288c57781
4 changed files with 1122 additions and 2 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

View file

@ -13,7 +13,11 @@
set -euo pipefail set -euo pipefail
# 3. encoding.xml — disable throttling + segment deletion (both containers if present) # 3+5. encoding.xml — disable throttling + segment deletion (HLS 499)
# AND disable software tonemapping (CPU-only nullstone
# cannot sustain real-time 4K HDR tonemap+x264, ffmpeg
# runs at ~0.5x → 18s wait time before video starts;
# R&M is fake-HDR per doc 21 anyway, so no visual loss)
for cfg in /home/docker/jellyfin/config/config/encoding.xml \ for cfg in /home/docker/jellyfin/config/config/encoding.xml \
/home/docker/jellyfin-dev/config/config/encoding.xml; do /home/docker/jellyfin-dev/config/config/encoding.xml; do
[ -f "$cfg" ] || continue [ -f "$cfg" ] || continue
@ -21,6 +25,8 @@ for cfg in /home/docker/jellyfin/config/config/encoding.xml \
sed -i \ sed -i \
-e 's|<EnableThrottling>true</EnableThrottling>|<EnableThrottling>false</EnableThrottling>|' \ -e 's|<EnableThrottling>true</EnableThrottling>|<EnableThrottling>false</EnableThrottling>|' \
-e 's|<EnableSegmentDeletion>true</EnableSegmentDeletion>|<EnableSegmentDeletion>false</EnableSegmentDeletion>|' \ -e 's|<EnableSegmentDeletion>true</EnableSegmentDeletion>|<EnableSegmentDeletion>false</EnableSegmentDeletion>|' \
-e 's|<EnableTonemapping>true|<EnableTonemapping>false|' \
-e 's|<EnableVppTonemapping>true|<EnableVppTonemapping>false|' \
"$cfg" "$cfg"
echo "[+] patched $cfg" echo "[+] patched $cfg"
done done
@ -44,7 +50,10 @@ patch = """
INC1: BLACK-PASS occludes backdrop; transparent-scope via :has(). INC1: BLACK-PASS occludes backdrop; transparent-scope via :has().
INC2: pin backdrop position:fixed so it persists across scroll. INC2: pin backdrop position:fixed so it persists across scroll.
INC3: extend transparent-scope through detail-page sub-sections so INC3: extend transparent-scope through detail-page sub-sections so
section wrappers don't paint over the pinned backdrop. */ section wrappers don't paint over the pinned backdrop.
INC4: override the 2026-05-08 .emby-scroller=#000 rule on detail page
(it was painting a black band behind every carousel — most visible
on admin-only "More from Season" / "More Like This"). */
.mainDetailButtons .material-icons.play_arrow::after { .mainDetailButtons .material-icons.play_arrow::after {
content: "Play" !important; content: "Play" !important;
} }
@ -111,6 +120,21 @@ patch = """
background-color: transparent !important; background-color: transparent !important;
background: transparent !important; background: transparent !important;
} }
/* INC4: 2026-05-08 home-page "kill gray band" rule paints .emby-scroller
#000 unscoped — that's the OPAQUE wrapper around every carousel inside
.itemDetailPage. Override back to transparent on detail page only. */
.itemDetailPage .emby-scroller,
.itemDetailPage .emby-scroller-container,
.itemDetailPage .verticalSection,
.itemDetailPage .padded-top-focusscale,
.itemDetailPage .padded-bottom-focusscale,
.itemDetailPage .moreFromSeasonSection,
.itemDetailPage .moreFromArtistSection,
.itemDetailPage .scrollSliderContainer,
.itemDetailPage .scrollButtonContainer {
background-color: transparent !important;
background: transparent !important;
}
""" """
s = s.replace("</CustomCss>", patch + "</CustomCss>") s = s.replace("</CustomCss>", patch + "</CustomCss>")
open(p, "w").write(s) open(p, "w").write(s)

629
bin/headless-test-v2.py Executable file
View file

@ -0,0 +1,629 @@
#!/usr/bin/env python3
"""ARRFLIX headless smoke-test v2.
Why v2 exists (see docs/26 INC4 audit):
v1 had three coverage gaps that let two regressions ship:
- Logged in only as `guest` (non-admin restricted) admin-only sections
like the "More from Season N" carousel never rendered, so the black
band behind that carousel was invisible to the test.
- Never clicked Play never observed the <video> element in a real
playback state, so AV1+Opus episodes silently rendering black went
undetected.
- Probed only a hardcoded selector list any element painting an
opaque background outside that list (e.g. a new section wrapper)
was never reported.
v2 closes those gaps:
1. Multi-user runs: executes the full probe as BOTH admin and non-admin
in the same invocation, writes per-user JSON + screenshots, and
reports a DOM-section diff (sections present for one user but not
the other admin-only-visible content).
2. Click Play: locates the play button, clicks it, waits 10 s, captures
<video> element state (currentTime, paused, error, readyState, dims),
plus a video-area screenshot and any new console / network errors.
3. Multiple-item coverage: walks an item list (default: HEVC movie + AV1
TV episode + H.264 TV episode if available) and runs the full
detail-page + play probe for each.
4. Section-bg sweep: at scroll-bottom, walks every visible element and
reports any with a non-transparent backgroundColor whose bounding rect
overlaps where the pinned backdrop should be visible. Output goes
into probe.json under "regressions" with an allowlist filter.
5. Golden-screenshot diff: if a known-good screenshot exists at
OUT/golden/<key>.png, the run computes a Pillow pixel diff and writes
<key>-diff.png + a numeric mismatch ratio.
6. Structured JSON: probe.json now has top-level shape
{url, runs:[{user, item, item_kind, probe, play, regressions, ...}]}
so downstream tooling (CI / agents) can parse without grepping.
Usage:
bin/headless-test-v2.py [URL] [OUT_DIR]
URL defaults to https://dev.arrflix.s8n.ru.
OUT_DIR defaults to /tmp/arrflix-headless-v2.
User credentials are determined automatically from URL:
arrflix.s8n.ru admin=s8n / guest=guest
dev.arrflix.s8n.ru admin=s8n-dev / guest=guest-mirror
Override via env vars:
ADMIN_USER, ADMIN_PASS, GUEST_USER, GUEST_PASS
ITEMS=id1,id2,id3 # override default item list
Default items (chosen for codec coverage):
- HEVC movie: 7aa5add2c2d8575eda5280b9b9072071 (The Dark Knight)
- AV1 episode: auto-pick first Mike Nolan Show episode
- H.264 episode: auto-pick first non-AV1 episode if available
Exit codes:
0 all runs succeeded, no playback errors, no regression bg elements
1 setup / login failure
2 one or more runs reported playback failure or unallowlisted bg regression
"""
import sys, json, time, os, asyncio, urllib.request, urllib.error, ssl
from pathlib import Path
from playwright.async_api import async_playwright
try:
from PIL import Image, ImageChops
PIL_OK = True
except ImportError:
PIL_OK = False
URL = sys.argv[1] if len(sys.argv) > 1 else "https://dev.arrflix.s8n.ru"
OUT = sys.argv[2] if len(sys.argv) > 2 else "/tmp/arrflix-headless-v2"
os.makedirs(OUT, exist_ok=True)
os.makedirs(os.path.join(OUT, "golden"), exist_ok=True)
# Default credentials by env (URL → admin/guest)
if "dev.arrflix.s8n.ru" in URL:
DEFAULT_ADMIN = ("s8n-dev", "2001dude")
DEFAULT_GUEST = ("guest-mirror", "dev-test-guest")
else:
DEFAULT_ADMIN = ("s8n", "2001dude")
DEFAULT_GUEST = ("guest", "123")
ADMIN_USER = os.environ.get("ADMIN_USER", DEFAULT_ADMIN[0])
ADMIN_PASS = os.environ.get("ADMIN_PASS", DEFAULT_ADMIN[1])
GUEST_USER = os.environ.get("GUEST_USER", DEFAULT_GUEST[0])
GUEST_PASS = os.environ.get("GUEST_PASS", DEFAULT_GUEST[1])
# Default items: HEVC movie known id; TV episodes auto-picked per-user
ITEMS_OVERRIDE = os.environ.get("ITEMS", "").strip()
DEFAULT_HEVC_MOVIE = "7aa5add2c2d8575eda5280b9b9072071" # Dark Knight
MNS_NEEDLE = "mike nolan" # case-insensitive substring of series name for AV1 lookup
DEVICE = "headless-test-v2"
DEVICE_ID = "headless-test-v2-2026-05-09"
CLIENT = "HeadlessV2"
VERSION = "2.0"
# Selectors known to legitimately paint solid bg over backdrop area; if a
# regression sweep finds a bg element NOT on this list overlapping the
# backdrop region, it is flagged. Update intentionally as design changes.
BG_ALLOWLIST = {
# OSD / video player overlays — fine to be opaque
".htmlVideoPlayer", ".videoPlayerContainer", ".osdContent",
".upNextDialog", ".dialogContainer", ".dialog",
# Modal / dialog scrim layers
".dialogBackdrop", ".paperList",
# Top app drawer (intentionally opaque)
".skinHeader", ".headerTop",
}
# ---------- HTTP helpers (raw API) ----------
def auth_header(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="{DEVICE}", '
f'DeviceId="{DEVICE_ID}", Version="{VERSION}"')
if token:
h += f', Token="{token}"'
return h
def _req(path, method="GET", body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{URL}{path}",
data=data,
headers={
"Authorization": auth_header(token),
"Content-Type": "application/json",
},
method=method,
)
ctx = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=ctx, timeout=15) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def login(user, password):
return _req("/Users/AuthenticateByName", "POST",
{"Username": user, "Pw": password})
def find_av1_episode(token, user_id):
"""Find first episode of Mike Nolan Show (or any series matching needle)."""
series = _req(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=200",
token=token)
target = None
for s in series.get("Items", []):
if MNS_NEEDLE in s.get("Name", "").lower():
target = s
break
if not target:
return None, None
eps = _req(
f"/Shows/{target['Id']}/Episodes?UserId={user_id}&Limit=1",
token=token)
if eps.get("Items"):
return eps["Items"][0]["Id"], f"{target['Name']} - {eps['Items'][0].get('Name','?')}"
return None, None
def find_h264_episode(token, user_id, exclude_series_id=None):
"""Auto-pick first episode of any TV series other than the AV1 one."""
series = _req(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=50",
token=token)
for s in series.get("Items", []):
if exclude_series_id and s.get("Id") == exclude_series_id:
continue
if MNS_NEEDLE in s.get("Name", "").lower():
continue
eps = _req(
f"/Shows/{s['Id']}/Episodes?UserId={user_id}&Limit=1",
token=token)
if eps.get("Items"):
return eps["Items"][0]["Id"], f"{s['Name']} - {eps['Items'][0].get('Name','?')}"
return None, None
def resolve_items(token, user_id):
"""Return list of [(item_id, label, kind), ...]."""
if ITEMS_OVERRIDE:
return [(i.strip(), f"override-{n}", "override")
for n, i in enumerate(ITEMS_OVERRIDE.split(",")) if i.strip()]
out = []
# HEVC movie (fixed id)
out.append((DEFAULT_HEVC_MOVIE, "Dark Knight (HEVC movie)", "hevc-movie"))
# AV1 episode (auto)
av1_id, av1_label = find_av1_episode(token, user_id)
if av1_id:
out.append((av1_id, f"{av1_label} (AV1 ep)", "av1-episode"))
# H.264 episode (auto, different series from AV1)
series_id_excl = None
if av1_id:
try:
ep = _req(f"/Users/{user_id}/Items/{av1_id}", token=token)
series_id_excl = ep.get("SeriesId")
except Exception:
pass
h264_id, h264_label = find_h264_episode(token, user_id, series_id_excl)
if h264_id:
out.append((h264_id, f"{h264_label} (H.264 ep)", "h264-episode"))
return out
# ---------- Playwright probe ----------
PROBE_SELECTORS = [
".itemBackdrop", ".detailBackdrop", ".backdropContainer",
".backgroundContainer", ".layout-desktop",
"body", "#reactRoot", ".itemDetailPage",
"video", ".htmlvideoplayer", ".btnPlay",
".detailPagePrimaryContainer", ".detailSection", ".detailVerticalSection",
".itemsContainer", ".padded-bottom-page", ".mainAnimatedPages",
".pageContainer", ".cardScalable", ".scrollSlider",
".sectionTitleContainer", ".detailPageContent", ".detailPageWrapperContainer",
".moreFromSeason", ".moreFromSeasonContainer", # admin-only carousel
]
async def probe_dom(page):
return await page.evaluate(
"""(SEL) => {
const result = {};
for (const s of SEL) {
const els = document.querySelectorAll(s);
if (!els.length) { result[s] = '<absent>'; continue; }
const el = els[0];
const cs = getComputedStyle(el);
result[s] = {
count: els.length,
display: cs.display,
opacity: cs.opacity,
visibility: cs.visibility,
background: cs.backgroundColor,
backgroundImage: cs.backgroundImage.slice(0, 80),
zIndex: cs.zIndex,
rect: el.getBoundingClientRect().toJSON(),
};
}
result.__title = document.title;
const playBtn = document.querySelector('.btnPlay, [data-action="play"]');
result.__playBtnText = playBtn
? (playBtn.innerText || playBtn.textContent || '').trim() : null;
result.__bodyClasses = document.body.className;
result.__url = location.href;
// List of all section-title texts so we can diff per-user.
result.__sectionTitles = Array.from(
document.querySelectorAll('.sectionTitleContainer, h2, .sectionHeader')
).map(e => (e.innerText || e.textContent || '').trim()).filter(Boolean);
return result;
}""",
PROBE_SELECTORS,
)
async def sweep_backgrounds(page):
"""Walk visible elements; return ones with non-transparent bg whose rect
overlaps where the pinned backdrop should be visible (top of viewport
above ~70% page height). The criterion is intentionally generous
callers filter via the allowlist."""
return await page.evaluate(
r"""() => {
const isOpaque = (c) => {
if (!c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent') return false;
const m = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\)/);
if (!m) return true;
const a = m[4] !== undefined ? parseFloat(m[4]) : 1.0;
return a > 0.05;
};
const out = [];
const all = document.querySelectorAll('*');
for (const el of all) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
if (!isOpaque(cs.backgroundColor)) continue;
const r = el.getBoundingClientRect();
if (r.width < 50 || r.height < 50) continue;
// Skip if bg is already same as body (chained inheritance, no diff)
if (el === document.body || el === document.documentElement) continue;
// Build a signature so consumers can match against allowlist
const cls = (el.className && typeof el.className === 'string')
? '.' + el.className.trim().split(/\s+/).join('.')
: '';
const sig = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + cls;
out.push({
sig: sig.slice(0, 200),
tag: el.tagName.toLowerCase(),
id: el.id || null,
classes: (typeof el.className === 'string') ? el.className : '',
background: cs.backgroundColor,
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
zIndex: cs.zIndex,
});
}
return out;
}"""
)
def filter_regressions(bg_elements, viewport_w, viewport_h):
"""Apply allowlist + overlap heuristics → regression list.
A bg element is flagged iff:
- It is NOT in the allowlist (any allowlist class appears in its sig).
- Its rect overlaps the visible viewport (x within [0, vw], y within
a band where backdrop should show i.e. above 80% page height
because content scrolls past pinned backdrop).
- The bg color is "very dark" (R+G+B < 90). Most legit overlays
are clearly tinted; near-black is the failure mode we want.
"""
regressions = []
for el in bg_elements:
sig = el["sig"]
if any(allow in sig for allow in BG_ALLOWLIST):
continue
bg = el["background"]
# Parse rgb sum
try:
nums = [int(x) for x in bg.replace("rgba(", "").replace("rgb(", "")
.replace(")", "").split(",")[:3]]
except Exception:
continue
if sum(nums) > 90:
continue
rect = el["rect"]
if rect["x"] + rect["w"] < 0 or rect["x"] > viewport_w:
continue
regressions.append(el)
return regressions
async def click_play_and_observe(page):
"""Find Play, click, wait 10s, return playback state + new errors."""
pre_console_marker = await page.evaluate("() => Date.now()")
state = {"clicked": False, "selector_used": None, "error": None}
# Try the canonical button selectors in priority order
for sel in [".btnPlay", "[data-action=\"play\"]", "button[is=\"emby-button\"][data-action=\"play\"]"]:
try:
btn = await page.query_selector(sel)
if btn:
box = await btn.bounding_box()
if box and box["width"] > 0:
await btn.click(timeout=5000)
state["clicked"] = True
state["selector_used"] = sel
break
except Exception as e:
state["error"] = f"{sel}: {e}"
if not state["clicked"]:
# Fallback: keyboard 'p' which Jellyfin web binds to play
try:
await page.keyboard.press("p")
state["clicked"] = True
state["selector_used"] = "kbd:p"
except Exception as e:
state["error"] = (state.get("error") or "") + f"; kbd:{e}"
return state
await asyncio.sleep(10)
state["video"] = await page.evaluate("""() => {
const v = document.querySelector('video');
if (!v) return { present: false };
const rect = v.getBoundingClientRect();
return {
present: true,
src: (v.src || '').slice(0, 200),
currentTime: v.currentTime,
paused: v.paused,
ended: v.ended,
readyState: v.readyState,
networkState: v.networkState,
error: v.error ? { code: v.error.code, message: v.error.message } : null,
videoWidth: v.videoWidth,
videoHeight: v.videoHeight,
duration: v.duration,
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
buffered_ranges: v.buffered.length,
};
}""")
state["pre_marker"] = pre_console_marker
return state
async def run_one(p, user, password, role, run_idx, console_messages, network_failures):
"""Execute the full probe sequence for one user. Returns dict for JSON."""
print(f"\n=== Run {run_idx}: {role} ({user}) ===")
auth = login(user, password)
token = auth["AccessToken"]
user_id = auth["User"]["Id"]
server_id = auth["ServerId"]
is_admin = auth["User"].get("Policy", {}).get("IsAdministrator", False)
print(f"[+] Auth OK uid={user_id} admin={is_admin}")
items = resolve_items(token, user_id)
if not items:
print("[!] No items resolvable — aborting run")
return {"role": role, "user": user, "is_admin": is_admin, "items": [],
"error": "no items"}
print(f"[+] Items: {[(i[1], i[2]) for i in items]}")
runs = []
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage",
"--autoplay-policy=no-user-gesture-required"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
page.on("console", lambda m: console_messages.append(
{"role": role, "user": user, "type": m.type, "text": m.text}))
page.on("requestfailed", lambda r: network_failures.append(
{"role": role, "user": user, "method": r.method, "url": r.url,
"failure": str(r.failure)}))
page.on("response", lambda r: None if r.status < 400 else
network_failures.append({"role": role, "user": user, "status": r.status,
"url": r.url}))
# --- form login (mirrors v1) ---
await page.goto(f"{URL}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
try:
await page.wait_for_selector("input", timeout=20000)
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => "
"({id:i.id, name:i.name, type:i.type, placeholder:i.placeholder}))")
user_sel = pass_sel = None
for i in inputs:
fid, fname, ftype = i.get("id", ""), i.get("name", ""), i.get("type", "")
if not user_sel and (ftype == "text" or "user" in (fid+fname).lower()
or "name" in (fid+fname).lower()):
user_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if not pass_sel and ftype == "password":
pass_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if user_sel and pass_sel:
await page.fill(user_sel, user)
await page.fill(pass_sel, password)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(2)
print(f"[+] form login OK as {user}")
else:
print("[!] login fields not found — continuing with API token")
except Exception as e:
print(f"[!] form login failed: {e}")
for item_id, label, kind in items:
target = f"{URL}/web/#/details?id={item_id}&serverId={server_id}"
print(f"\n[*] {role}/{kind}: {label}{target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4)
probe = await probe_dom(page)
viewport = page.viewport_size
vw, vh = viewport["width"], viewport["height"]
# Top + scrolled screenshots
safe_user = user.replace("@", "_").replace("/", "_")
key = f"{safe_user}-{kind}"
top_png = os.path.join(OUT, f"{key}-top.png")
await page.screenshot(path=top_png, full_page=False)
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight * 0.5)")
await asyncio.sleep(1)
mid_png = os.path.join(OUT, f"{key}-mid.png")
await page.screenshot(path=mid_png, full_page=False)
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(1)
bot_png = os.path.join(OUT, f"{key}-bot.png")
await page.screenshot(path=bot_png, full_page=False)
# Background sweep at scroll-bottom (where INC4-style bands manifest)
bg_elements = await sweep_backgrounds(page)
regressions = filter_regressions(bg_elements, vw, vh)
print(f"[*] bg elements: {len(bg_elements)} regressions: {len(regressions)}")
# Click Play and observe
await page.evaluate("() => window.scrollTo(0, 0)")
await asyncio.sleep(1)
play_state = await click_play_and_observe(page)
play_png = os.path.join(OUT, f"{key}-play.png")
await page.screenshot(path=play_png, full_page=False)
# Diff vs golden
diffs = []
for shot in [(top_png, "top"), (mid_png, "mid"), (bot_png, "bot"),
(play_png, "play")]:
golden = os.path.join(OUT, "golden", f"{key}-{shot[1]}.png")
if PIL_OK and os.path.exists(golden):
try:
a = Image.open(shot[0]).convert("RGB")
b = Image.open(golden).convert("RGB")
if a.size != b.size:
diffs.append({"shot": shot[1], "error": "size mismatch"})
continue
diff_img = ImageChops.difference(a, b)
bbox = diff_img.getbbox()
diff_path = os.path.join(OUT, f"{key}-{shot[1]}-diff.png")
diff_img.save(diff_path)
# Numeric mismatch ratio
hist = diff_img.histogram()
nonzero = sum(hist[i] for i in range(1, 256))
total = a.size[0] * a.size[1] * 3
ratio = nonzero / total if total else 0
diffs.append({"shot": shot[1], "bbox": bbox, "ratio": ratio,
"diff_path": diff_path})
except Exception as e:
diffs.append({"shot": shot[1], "error": str(e)})
runs.append({
"item_id": item_id, "label": label, "kind": kind,
"screenshots": {"top": top_png, "mid": mid_png, "bot": bot_png,
"play": play_png},
"probe": probe,
"play": play_state,
"bg_count": len(bg_elements),
"regressions": regressions,
"diffs_vs_golden": diffs,
})
await browser.close()
return {"role": role, "user": user, "is_admin": is_admin,
"items": runs}
def section_title_diff(admin_run, guest_run):
"""Return sections present for admin but not guest (admin-only carousels)."""
diffs = []
a_items = {i["kind"]: i for i in admin_run.get("items", [])}
g_items = {i["kind"]: i for i in guest_run.get("items", [])}
for kind in a_items:
if kind not in g_items:
continue
a_titles = set(a_items[kind].get("probe", {}).get("__sectionTitles", []))
g_titles = set(g_items[kind].get("probe", {}).get("__sectionTitles", []))
only_admin = sorted(a_titles - g_titles)
only_guest = sorted(g_titles - a_titles)
if only_admin or only_guest:
diffs.append({"kind": kind, "only_admin": only_admin,
"only_guest": only_guest})
return diffs
def grade(result):
"""Decide pass/fail. Returns (exit_code, summary)."""
issues = []
for run in result["runs"]:
for item in run.get("items", []):
v = item.get("play", {}).get("video", {})
if not v.get("present"):
issues.append(f"{run['user']}/{item['kind']}: <video> absent")
elif v.get("error"):
issues.append(f"{run['user']}/{item['kind']}: video error "
f"code={v['error'].get('code')}")
elif v.get("readyState", 0) < 2:
issues.append(f"{run['user']}/{item['kind']}: video readyState="
f"{v.get('readyState')} (no current data)")
elif v.get("paused") and v.get("currentTime", 0) == 0:
issues.append(f"{run['user']}/{item['kind']}: video paused at t=0")
if item.get("regressions"):
issues.append(f"{run['user']}/{item['kind']}: "
f"{len(item['regressions'])} bg regression(s)")
return (2 if issues else 0, issues)
async def main():
console_messages = []
network_failures = []
print(f"[+] Target: {URL}")
print(f"[+] OUT: {OUT}")
print(f"[+] Admin: {ADMIN_USER}")
print(f"[+] Guest: {GUEST_USER}")
async with async_playwright() as p:
admin_run = await run_one(p, ADMIN_USER, ADMIN_PASS, "admin", 1,
console_messages, network_failures)
guest_run = await run_one(p, GUEST_USER, GUEST_PASS, "guest", 2,
console_messages, network_failures)
section_diff = section_title_diff(admin_run, guest_run)
result = {
"url": URL,
"timestamp": int(time.time()),
"runs": [admin_run, guest_run],
"section_title_diff": section_diff,
"console": console_messages[-200:],
"network_failures": network_failures[-200:],
}
code, issues = grade(result)
result["issues"] = issues
result["exit_code"] = code
with open(os.path.join(OUT, "probe.json"), "w") as f:
json.dump(result, f, indent=2, default=str)
print(f"\n=== SUMMARY ===")
print(f"console: {len(console_messages)} network failures: {len(network_failures)}")
print(f"section diffs: {len(section_diff)}")
if section_diff:
for d in section_diff:
if d["only_admin"]:
print(f" admin-only ({d['kind']}): {d['only_admin']}")
if issues:
print(f"ISSUES ({len(issues)}):")
for i in issues:
print(f" - {i}")
else:
print("no issues detected")
print(f"probe.json: {os.path.join(OUT, 'probe.json')}")
sys.exit(code)
if __name__ == "__main__":
try:
asyncio.run(main())
except urllib.error.HTTPError as e:
print(f"[!] HTTP error during login: {e}")
sys.exit(1)
except Exception as e:
print(f"[!] fatal: {e}")
raise

View file

@ -541,6 +541,290 @@ These are the dead-ends. Future operators (and future me) should skip:
## Iteration 2 — backdrop visible only on top viewport (2026-05-09 follow-up) ## Iteration 2 — backdrop visible only on top viewport (2026-05-09 follow-up)
### INC4 online research
Web sweep 2026-05-09 against jellyfin/jellyfin + jellyfin/jellyfin-web
issues filed since 2025-01. All URLs cited inline. "Verdict" = how strong
the link to our two open symptoms (black-screen video, opaque "More from
Season" band) is.
**Q1 — Web 10.10.3 video black-screen on play (server transcoding HLS,
browser shows nothing):**
- jellyfin-webos #126 "Black screen by enable Prefer FMP4-HLS as media
container" — HEVC Main10 HDR10 10-bit direct-stream goes black, audio
fine. Workaround: disable Prefer fMP4-HLS.
https://github.com/jellyfin/jellyfin-webos/issues/126
- jellyfin-web #7405 "HLS Media Errors only in Webbrowsers."
https://github.com/jellyfin/jellyfin-web/issues/7405
- jellyfin #16612 "Playback errors due to fMP4-HLS" (10.11.8, but root
cause is fMP4 container; same workaround).
https://github.com/jellyfin/jellyfin/issues/16612
- forum t-solved-black-screen … web UI 10.0.3: theme `.preload { #000
!important }` covered the player. Direct precedent for our symptom.
https://forum.jellyfin.org/t-solved-black-screen-w-audio-when-playing-video-web-ui-10-0-3
- **Verdict: probable.** Two independent vectors:
(1) fMP4-HLS container produces an init segment hls.js stalls on for
certain codec profiles;
(2) custom-CSS overlay covering the player. Both consistent with our
black-screen-but-server-transcoding behaviour.
- **Next step:** in DevTools, confirm whether `<video>` has frames
(network MSE buffer) or is occluded. If the SourceBuffer never
appendBuffer-s, it's #126/#16612 → toggle off "Prefer fMP4-HLS Media
Container" in playback settings (or strip from custom DeviceProfile).
If frames are buffered but invisible, search for an opaque ancestor
(`.preload`, BLACK-PASS rule covering `.videoPlayerContainer`).
**Q2 — Chrome 148 + `-hls_fmp4_init_filename "X-1.mp4"` MSE compatibility:**
- jellyfin-web #7546 "[Regression] Web browser HLS playback times out
when audio transcoding required - worked in 10.10.7, broken in 10.11.6"
— hls.js times out waiting for the first segment while ffmpeg probes
large files.
https://github.com/jellyfin/jellyfin-web/issues/7546
- jellyfin #14487 "Audio delay don't work with fMP4-HLS."
https://github.com/jellyfin/jellyfin/issues/14487
- jellyfin #16647 "HLS subtitle X-TIMESTAMP-MAP is misaligned when using
fMP4 segments."
https://github.com/jellyfin/jellyfin/issues/16647
- **Verdict: confirmed broken across 10.10.7 → 10.11.x for some
codec/container combos.** Not Chrome-148-specific; the init-filename
pattern itself isn't the bug — the timing between ffmpeg probing and
hls.js segment-load timeout is.
- **Next step:** disable Prefer fMP4-HLS first (single-toggle fix). If
still broken, drop probesize + analyzeduration on the encoder side, or
force ts segments via DeviceProfile TranscodingProfile container=ts.
**Q3 — AV1 DirectStream codec-tag mislabel:**
- jellyfin #15646 "AV1 Video Stream in Wrong Container" — av1 muxed into
mpegts as private-data stream, ffmpeg warning "may not be recognized
upon reading". Workaround: switch hls_segment_type from mpegts to
fmp4 with .m4s extension. Marked closed in UI but in Team Review (no
PR linked, no version-tag yet).
https://github.com/jellyfin/jellyfin/issues/15646
- Codec Support docs reaffirm AV1 web playback is gated on browser
support + correct container.
https://jellyfin.org/docs/general/clients/codec-support/
- **Verdict: confirmed open.** Affects 10.11.3 and back; no PR landed
in 10.10.x line. Mike Nolan Show AV1+Opus matches the failure pattern.
- **Next step:** ban AV1 DirectStream via custom DeviceProfile
(drop AV1 from DirectPlayProfiles → forces server-side libx264 transcode).
**Q4 — "More from Season" CSS class names:**
- jellyfin-web source uses `verticalSection` + `detailVerticalSection`
pair, with `data-type="MusicAlbum|Episode|...".`
https://github.com/tedhinklater/JellyfinThemeGuide
- Layouts reference `.scrollSlider`, `.itemsContainer`, `.padded-left`,
`.sectionTitleContainer` (already in our Iteration 2 fix list).
### INC4 video playback diagnosis (full e2e)
End-to-end test 2026-05-09 ~01:35 UTC. Temp ApiKey
`arrflix-playback-e2e-2026-05-09` (token rotated, deleted at end, verified
SELECT empty). Headless Chromium via playwright drove the SPA login as
guest:123 and clicked .btnPlay on Rick & Morty S1E1 Pilot
(`324f75b84f394a5d9b0749c0679f23b9`). Logs in `/tmp/arrflix-playback-e2e/`.
**Source codec verdict — Rick & Morty Pilot is NOT H.264.** ffprobe inside
container reports the file is HEVC Main 10 / yuv420p10le / 3840x2160 /
TrueHD 5.1 24-bit + AC3 5.1 + AC3 2.0 + PGS subs (4K HDR). Same codec class
as TDK. The task brief assumption ("Rick & Morty likely H.264") is wrong —
this library is 4K HDR remux. Path:
`/media/tv/Rick and Morty (2013)/Season 01/Rick and Morty (2013) - S01E01 - Pilot.mkv`.
**Failure mode at click — playback DOES work, but takes 12-18s to first
frame.** All segments + manifest 200 OK, no console errors, no video.error,
no MediaSource exception, no CSS occlusion (.htmlvideoplayer / `<video>`
display:block opacity:1 visibility:visible z-index:auto, getBoundingClientRect
== full viewport). State timeline (clean run, position reset to 0):
| t (s) | readyState | networkState | currentTime | buffered |
|---|---|---|---|---|
| 2-10 | 0 (HAVE_NOTHING) | 2 (LOADING) | 0 | [] |
| 12 | 3 (HAVE_FUTURE_DATA) | 2 | 0 | [[0, 2.97]] |
| 16 | 3 | 2 | 0.72 | [[0, 5.97]] |
| 22 | 3 | 2 | 6.74 | [[0, 11.99]] |
| 30 | 3 | 2 | 14.75 | [[0, 14.97]] |
With user's actual stored resume position (243.018 s from prior session),
adds a kill+restart cycle: SPA fetches segment 0, sees currentTime=243,
seeks → server kills 1st ffmpeg, launches 2nd with `-ss 00:04:03
-noaccurate_seek -start_number 81`. Browser stays at readyState=1 from
~t=8s to ~t=16s while 2nd ffmpeg produces segment 81. **Total wait ≈ 18s
to first painted frame.** From the user's seat that looks identical to a
broken player.
**Server-side ffmpeg command (verified live in jellyfin logs):**
```
/usr/lib/jellyfin-ffmpeg/ffmpeg -analyzeduration 200M -probesize 1G \
-i "/media/tv/Rick and Morty (2013)/Season 01/...Pilot.mkv" \
-map 0:0 -map 0:1 -codec:v:0 libx264 -preset veryfast -crf 23 \
-maxrate 13546858 -profile:v:0 high -level 51 \
-vf "setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc,\
scale=trunc(min(max(iw,ih*a),min(3840,2160*a))/2)*2:trunc(min(max(iw/a,ih),min(3840/a,2160))/2)*2,\
tonemapx=tonemap=bt2390:desat=0:peak=100:t=bt709:m=bt709:p=bt709:format=yuv420p" \
-codec:a:0 libfdk_aac -ac 2 -ab 256000 \
-hls_segment_type fmp4 -hls_fmp4_init_filename "...-1.mp4" \
-start_number 0 -hls_segment_filename "/cache/transcodes/...%d.mp4" \
-f hls -hls_time 3 ...
```
`HardwareAccelerationType=none` + 4K + tonemapx + libx264 veryfast +
software stereo downmix. **Per-segment encode wallclock observed:** seg0
~6 s, seg1 ~2.05 s. At nullstone Ryzen 5 5600G CPU-only, that's ~50% of
real-time on a sustained run. Browser stalls because new segments arrive
slower than they're consumed.
**PlaybackInfo verdict (browser-emulating DeviceProfile, av1+h264+vp9 both
allowed):** `SupportsDirectPlay=False`, `SupportsDirectStream=False`,
`SupportsTranscoding=True`,
`TranscodeReasons=ContainerNotSupported,VideoCodecNotSupported,AudioCodecNotSupported`,
`TranscodingSubProtocol=hls`, `TranscodingContainer=ts` (when client asks
ts) — but in the headless run the SPA's stock DeviceProfile asks
`SegmentContainer=mp4` (fmp4 path) and the server picked **libx264 H.264
high@5.1 8-bit**, NOT av1. The `VideoCodec=av1,h264,vp9` in the URL is the
priority list; server reads it and selects the first the source can map
to without HW — that's libx264 here, confirmed by `-codec:v:0 libx264` in
ffmpeg cmdline. AV1 is never used as a transcode target on prod.
**Web research corroboration:**
- jellyfin#13324 "Transcoded playback of 4K HDR content fails": "no modern
consumer CPU can transcode 4K HDR to SDR in real time" — software
tonemapping is the bottleneck.
https://github.com/jellyfin/jellyfin/issues/13324
- jellyfin#5067 "HDR Tone Mapping is very slow in Jellyfin (19fps, 70%
cpu)": ~20 fps cap on tonemapx.
https://github.com/jellyfin/jellyfin/issues/5067
- jellyfin docs Hardware Acceleration: software CPU decode + tonemap +
encode at 4K HDR is officially "not supported for sustained real-time".
https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/
**Recommended fix (ordered by reversibility + UX impact):**
1. **Cap user MaxStreamingBitrate to 20 Mbps in jellyfin-web settings.**
Each user → Profile → Playback → Quality → 20 Mbps (or "Auto" with a
default cap). Server-side ffmpeg still runs but `-maxrate 20000000`
matched output bitrate is reasonable and the scale filter clamps to
1080p (1920x800 for the source aspect), eliminating the 4K scale
pass. Reduces per-segment encode wallclock from ~6s → ~1.5s. **Single
toggle, per-user, no server restart, fully reversible.** This is the
right move first.
2. **Force libx264 + transcoding container=ts via DeviceProfile (or in
jellyfin-web settings disable "Prefer fMP4-HLS").** Skips the fmp4
init-segment path which is implicated in jellyfin#16612 / webos#126
for HEVC Main10 sources. `ts` segments self-contain init data —
ssimpler timing.
3. **Disable software tonemapping for libraries with fake-HDR sources.**
Doc 21 already established R&M's `MasteringDisplay/MaxCLL` are absent
(fake AI-upscale HDR). Server-side toggle:
```
ssh user@192.168.0.100 'docker exec jellyfin sh -c "\
sed -i \"s|<EnableTonemapping>true|<EnableTonemapping>false|\" \
/config/config/encoding.xml" && docker restart jellyfin'
```
Removes the tonemapx step from the filtergraph. Output will be SDR-
directly-from-HDR-pixels (washed out per doc 21 — already accepted as
the lesser evil for R&M). Saves ~30% encode CPU at 4K.
4. **(Last resort, deferred to 10.11.8 migration)** Add a CCR-style
"transcode pre-warm" hook: when SPA opens a detail page, pre-issue
`/Items/{id}/PlaybackInfo` + a no-op range request on segment 0 to
start ffmpeg before the user clicks Play. Reduces perceived TTFP.
**Recommended immediate action: option 1 + option 3.** No code change
needed — both are settings flips. After flipping, repro: open Pilot in
Chrome, click Play, time-to-first-frame should be <5s.
**Headless artefact warning:** the `v2-02-after-30s.png` screenshot is
pure black despite readyState=3 + currentTime advancing + buffered=[0,
14.97]. That is because Chromium without GPU does not paint decoded H.264
frames (no compositor target). Real Chrome on real GPU paints. So a
black screenshot from `bin/headless-test.py` after Play is NOT a CSS bug
— it's a headless rendering artefact. Verify CSS occlusion via
`getComputedStyle` + `getBoundingClientRect` instead, both already clean
in this run.
**Open follow-ups left:** AV1+Opus episodes (Mike Nolan Show) still
untested in this iteration — different failure mode (DirectStream
codec-tag mislabel per Q3 above), separate fix path.
https://deepwiki.com/jellyfin/jellyfin-web/3.5-home-sections-and-library-navigation
- BobHasNoSoul/jellyfin-mods uses `#itemDetailPage` parent + nth-of-type
for section targeting.
https://github.com/BobHasNoSoul/jellyfin-mods
- **Verdict: confirmed.** The wrapper is `.verticalSection.detailVerticalSection`
(no `moreFromSeasonSection` class — Jellyfin distinguishes sections by
`data-type` attr, not class). Our INC3 selector list already covers
`.detailVerticalSection*`, so the opaque band is from a DESCENDANT, not
the wrapper itself. Likely candidates: a `.cardScalable`, `.cardBox`, or
`.cardImageContainer` with explicit `background:#000` from BLACK-PASS.
- **Next step:** in DevTools, inspect the opaque band, walk parent chain,
find the first ancestor with non-transparent computed bg. Either
add to transparent-scope or wrap selector in `:not(.cardImageContainer)`.
**Q5 — Themes implementing full-page persistent backdrop:**
- meow.garden "Dynamic backdrops for Jellyfin" — uses
`.detailPagePrimaryContainer .detailImageContainer .blurhash-canvas {
position: fixed !important; opacity: .5; }` to repurpose the blurhash
placeholder as a fullscreen backdrop.
https://meow.garden/jellyfin-dynamic-backdrops/
- Cineplex theme custom.css: targets `.backgroundContainer`,
`.backgroundContainer.withBackdrop`, `.backdropImage`, `.blurhash-canvas`
(commented out). Mobile-only `.itemBackdrop` mask gradient.
https://github.com/MRunkehl/cineplex
- Finity theme: minimal docs, refers to "gradient mask for show backdrops"
but actual selectors live in CSS files (not exposed in README).
https://github.com/prism2001/finity
- **Verdict: confirmed.** Two viable patterns:
(1) pin `.backgroundContainer` (our current INC2 approach) — works but
must transparent-scope every ancestor;
(2) repurpose `.blurhash-canvas` as the fixed layer (meow.garden) —
cleaner because blurhash is already per-item; survives section navigation
without scroll math.
- **Next step:** if INC3 transparent-scope keeps regressing, switch to
blurhash-canvas pin. One selector vs ~20 wrappers to keep transparent.
**Q6 — 10.10.3 → 10.10.7 worth bumping?**
- 10.10.7 forum announcement (2025-04-05): security release, "several
bugfixes." Trusted-proxies config required pre-upgrade.
https://forum.jellyfin.org/t-new-jellyfin-server-web-release-10-10-7
- Compare-page diff (v10.10.3...v10.10.7) didn't generate (too long).
Releasebot lists per-release notes:
https://releasebot.io/updates/jellyfin/jellyfin-server
- Most fMP4/HLS fixes in our research target 10.11.x line, not 10.10.x
patch series.
- **Verdict: probable mild improvement, not a fix for our bugs.** Worth
bumping for security/CVE coverage but unlikely to resolve black-screen
or carousel-band. The known regressions of 10.11.x (`#7546`, `#16612`)
argue against jumping straight to 10.11.8 without dev validation.
- **Next step:** snapshot DB, bump dev to 10.10.7 first. If still broken,
10.11.8 is roadmap path with ElegantFin theme swap.
**Q7 — Force-transcode-everything DeviceProfile:**
- Jellyfin docs confirm there's no built-in admin toggle to force
transcoding for all clients.
https://jellyfin.org/docs/general/post-install/transcoding/
- forum.jellyfin.org/t-force-trasnscoding-or-disable-directplay: community
workaround is reduce client max bitrate to 1Mbps (degrades quality) —
no clean DeviceProfile-only override.
https://forum.jellyfin.org/t-force-trasnscoding-or-disable-directplay-x265-stuttering-firetv
- jellyfin-web #7651 "Chrome DeviceProfile hardcodes MKV in
DirectPlayProfiles": JS-Injector plugin removes entries client-side
before PlaybackInfo POST. Workaround pattern is generalisable: hook
PlaybackInfo XHR, set `DirectPlayProfiles=[]`, leave only
`TranscodingProfiles` with H264 mp4/HLS. Server then has nothing to
match → forces transcode.
https://github.com/jellyfin/jellyfin-web/issues/7651
- **Verdict: confirmed pattern, no native config knob.** Server-side
empty DirectPlayProfiles in a custom DeviceProfile is the cleanest
bypass; only ts-format TranscodingProfile remaining → libx264.
- **Next step:** create custom DeviceProfile in admin → DLNA → Profiles
with empty DirectPlay + a single TranscodingProfile (Container=mp4,
VideoCodec=h264, AudioCodec=aac, Protocol=Hls). Match to Identification
by browser UA. Eliminates codec compat as a variable in one move and
is the cleanest test for "is the bug in our codec path or our renderer".
---
After INC1 (`:has()` transparent-scope) shipped and prod showed backdrop on After INC1 (`:has()` transparent-scope) shipped and prod showed backdrop on
detail-page top, owner reported "in the middle of the More from Season 1 detail-page top, owner reported "in the middle of the More from Season 1
is black, it's hiding the artwork". Below-the-fold sections (Next Up, Seasons, is black, it's hiding the artwork". Below-the-fold sections (Next Up, Seasons,
@ -594,6 +878,79 @@ in playwright stretches viewport and hides `position:fixed` issues.
--- ---
### INC4 black-band locator (2026-05-09)
**Symptom.** After INC3, owner reported that for ADMIN users a wide black
band (~250px tall, full-width) still painted around the "More from Season 1"
carousel on the Rick & Morty detail page (admin-only carousel; guest users
don't see it). Cards rendered fine, only the BAND around them was opaque.
**Diagnostic method.** Inserted temp `arrflix-band-diag-2026-05-09` ApiKey,
logged in as admin via playwright, navigated to R&M detail page, scrolled
all sections into view, then walked DOM upward from each `.scrollSlider`
restricted to the `.itemDetailPage` subtree, reporting every ancestor with
non-transparent background. Locator script: `/tmp/arrflix-band-locator.py`.
**Result.** Single opaque-black wrapper found, identical for ALL 9
carousels (Schedule / Next Up / Seasons / Additional Parts / Lyrics /
Cast & Crew / Special Features / Music Videos / Scenes / **More Like This** /
**More from Season** / **More from Artist**):
```
div.padded-top-focusscale.padded-bottom-focusscale.no-padding.emby-scroller
bg = rgb(0, 0, 0) pos = static z = auto
rect = x:80 y:1242 1488×333 (matches the band the user described)
```
**Root cause.** Pre-existing CSS rule in `branding.xml` from 2026-05-08
labelled `/* kill gray band behind home-page Recently Added rows */` applied
`.emby-scroller { background: #000 !important; }` UNSCOPED. INC3 overrode
its sibling wrappers (`.detailVerticalSection`, `.itemsContainer`,
`.scrollSlider`, `.scrollSliderContainer`) but missed the IMMEDIATE PARENT
`.emby-scroller`. That single wrapper was the band.
**Fix INC4.** Detail-page-scoped transparent override appended to CustomCss
after the INC3 block:
```css
.itemDetailPage .emby-scroller,
.itemDetailPage .emby-scroller-container,
.itemDetailPage .verticalSection,
.itemDetailPage .padded-top-focusscale,
.itemDetailPage .padded-bottom-focusscale,
.itemDetailPage .moreFromSeasonSection,
.itemDetailPage .moreFromArtistSection,
.itemDetailPage .scrollSliderContainer,
.itemDetailPage .scrollButtonContainer {
background-color: transparent !important;
background: transparent !important;
}
```
No `position:relative; z-index:1` needed on `.emby-scroller` — the parent
`.detailPageWrapperContainer` already has `position:relative; z-index:2`,
which is above the pinned `.backdropContainer` at `z:0`. Removing the opaque
fill alone is sufficient.
**Verification.** Re-ran band-locator after `docker restart jellyfin`
`opaqueBlackBands: 0` inside `.itemDetailPage` (was 1). Screenshot of R&M
detail page at mid-scroll now shows portal/Easter Island backdrop continuous
behind every carousel including "More Like This". Cleaned up the
`arrflix-band-diag-2026-05-09` ApiKey row.
**Patch lines added** to `bin/apply-26-incident-fixes.sh` so re-runs are
idempotent and recover from `branding.xml` drift.
**Lesson.** When a prior unscoped `background: #000 !important` rule exists
in a shared CSS bucket (here: `branding.xml CustomCss`), grep the file for
the property/selector BEFORE writing a new transparent-scope rule. A
DOM-walking locator script that reports every opaque ancestor of the target
finds the painter in seconds — much faster than guessing selectors. Going
forward: when adding a "paint opaque" rule, scope it from day one
(`.homePage .emby-scroller`, not bare `.emby-scroller`).
---
## Open follow-ups (for separate sessions) ## Open follow-ups (for separate sessions)
- **AV1+Opus playback** (Bug E): Chrome's AV1 DirectStream codec-tag mislabel - **AV1+Opus playback** (Bug E): Chrome's AV1 DirectStream codec-tag mislabel
@ -614,3 +971,112 @@ in playwright stretches viewport and hides `position:fixed` issues.
- **Session-state backup off-host** (ROADMAP H4): no automated backup yet. - **Session-state backup off-host** (ROADMAP H4): no automated backup yet.
Today's incident was rescued by inline `cp X X.bak.$(date +%s)` for both Today's incident was rescued by inline `cp X X.bak.$(date +%s)` for both
branding.xml and dynamic.yml — should be systematized. branding.xml and dynamic.yml — should be systematized.
---
## Iteration 2
### INC4 testing methodology audit
This iteration is a meta-audit on the test that signed off Iteration 1.
After INC1INC3 shipped, owner reported two regressions the headless test
did NOT catch:
1. A black band painted behind the **"More from Season N"** carousel on
detail pages.
2. **Video plays as a black screen** on the user's actual TV episode
content (AV1+Opus from Mike Nolan Show), even though the test claimed
playback was fixed.
This section documents what the v1 test missed, why those gaps existed,
what `bin/headless-test-v2.py` changes, and the preflight protocol every
future fix must pass before claiming "verified".
#### a) What v1 missed
| Gap | Concrete consequence |
|---|---|
| Logged in **only** as `guest` (non-admin restricted user). | The "More from Season N" carousel is admin-visible content. `guest`'s permissions hid it from the DOM, so the section wrapper that painted the black band never rendered during the test. v1 reported "no regression" because the offending element wasn't on the page it screenshotted. |
| **Never clicked Play.** v1 only loaded the detail page, took screenshots, scraped a small fixed selector list. | A `<video>` element that fails to decode (AV1 in Chrome with mislabelled codec tag, per Bug E in this doc) won't show up unless you actually start playback. v1 had no way to observe `video.error`, `video.readyState`, `videoWidth/Height`, or `currentTime` because the player was never instantiated. |
| **Only one item tested.** v1 auto-picked the first Series and probed its detail page. | Codec coverage was random — usually whatever happened to be first alphabetically. The HEVC movie that worked (Dark Knight) and the AV1 episode that didn't (Mike Nolan Show) had different failure modes; v1 couldn't distinguish them because it tested neither systematically. |
| **Hardcoded selector list** for DOM probe. | v1 inspected ~22 known selectors. Any new section wrapper (e.g. `.moreFromSeasonContainer`) painting an opaque background outside that list was invisible. The black band lived in a wrapper v1 didn't even know existed. |
| **No structured pass/fail criterion.** v1 emitted `probe.json` with raw computed-style snapshots; humans had to read it and decide. | "I declared playback fixed" — that human decision had no machine-verifiable backing. There was no JSON field saying `regressions: []` that owner / next-Claude could trust without re-deriving from raw data. |
| **No cross-reference to a known-good baseline.** | Even if v1 had caught the band, there was no golden-image comparison to alert "this looks different from last passing run". Detection relied on someone eyeballing the screenshot. |
#### b) Why those gaps existed
- **Speed-bias.** v1 was written under time pressure as the third-tier
verification of an INC3 CSS fix. The minimum viable test was "page
loads and looks right at top + scrolled". That worked for the visual
bug it was designed against — and stopped there.
- **No threat model for the test itself.** The test never asked "what
classes of regression CAN I detect, what classes CAN'T I". If it had,
the missing-Play and admin-only-content gaps would have been obvious.
- **Single-account convenience.** `guest-mirror` was the easiest creds
to hand because doc 17 had just minted them. Re-using one role across
the whole verification was the path of least resistance.
- **Selector tunnel-vision.** The selector list was copied from the
previous fix's diagnostic queries (INC2/INC3). It tracked what the
previous bugs touched, not what the current page actually rendered.
- **Server-log success treated as proof of client success.** Bug E was
declared "fixed" because Dark Knight transcoding logs looked clean.
No one closed the loop and confirmed the user's actual content
(Mike Nolan Show / AV1) decoded in a real browser.
#### c) What v2 changes (`bin/headless-test-v2.py`)
| Improvement | Mechanism |
|---|---|
| **Multi-user coverage** | Runs the entire probe twice: once as admin (`s8n` / `s8n-dev`), once as non-admin (`guest` / `guest-mirror`). Per-user screenshots + `probe.json`. Computes a `section_title_diff` listing which sections rendered for one role but not the other — that diff is the canonical alert for "you're missing admin-only content". |
| **Click Play + observe** | After detail page settles, locates `.btnPlay` / `[data-action="play"]`, clicks (with keyboard `p` fallback), waits 10 s, then reads `<video>` element state: `currentTime`, `paused`, `ended`, `readyState`, `networkState`, `videoWidth`, `videoHeight`, `error.code`, `buffered_ranges`. Also captures a `*-play.png` screenshot and accumulates new console / network errors during the playback window. |
| **Multiple-item coverage** | Three items per role: HEVC movie (Dark Knight, hardcoded id `7aa5add2c2d8575eda5280b9b9072071`), AV1 episode (auto-picked from Mike Nolan Show), H.264 episode (auto-picked from a different series). Codec types are labelled in JSON so failures can be attributed to a codec class, not "the test failed". `ITEMS=` env var overrides for ad-hoc runs. |
| **Section-bg sweep** | At scroll-bottom, walks `document.querySelectorAll('*')` and reports every visible element with non-transparent `backgroundColor` whose rect overlaps the viewport. Filters via a small `BG_ALLOWLIST` (video player, dialogs, header) and a darkness heuristic (R+G+B < 90 likely a black-band regression). Output goes into `probe.json` under `runs[].items[].regressions`. |
| **Golden-screenshot diff** | If `OUT/golden/<key>-{top,mid,bot,play}.png` exists, the run computes a Pillow `ImageChops.difference`, writes a diff PNG, and emits `{bbox, ratio}` per shot. Maintainer can populate goldens after the next clean run; subsequent runs flag drift quantitatively. |
| **Structured pass/fail JSON** | `probe.json` now has stable shape: `{url, runs:[{role, user, is_admin, items:[{kind, probe, play, regressions, diffs_vs_golden}]}], section_title_diff, issues, exit_code}`. `grade()` produces `issues[]` and exits 0/2 deterministically. CI / orchestration can `jq '.issues | length' probe.json`. |
| **Documented invariants up front** | The script header explicitly lists "what v1 missed and how v2 closes it" so the next person reading it doesn't repeat the speed-bias trap. |
#### d) Preflight protocol — do this before claiming any ARRFLIX fix is "verified"
Treat this list as a hard gate. If any step is skipped, the fix is
**unverified**, not "fixed".
1. **Run v2 with both roles.** `bin/headless-test-v2.py https://dev.arrflix.s8n.ru`.
Confirm exit code 0 AND `probe.json .issues` is empty. If exit code 2,
read `.issues[]` — those are concrete regressions, not flaky test noise.
2. **Inspect `section_title_diff`.** A non-empty `only_admin` array means
the admin sees content the guest doesn't — that section MUST be
verified visually in the admin screenshots, because guest-only testing
would have been blind to it.
3. **Confirm playback per codec.** For each item in `runs[].items[]`,
`play.video.readyState` must be ≥ 2 AND `play.video.error` must be
`null`. `paused` is acceptable iff `currentTime > 0` (autoplay policy
may pause after the first frame, but a frame DID render). `videoWidth`
and `videoHeight` must be > 0 — that's the canonical "actually
decoding" check.
4. **Sweep flagged dark backgrounds.** Any element in
`runs[].items[].regressions` that is not a known overlay (dialog,
video player chrome, drawer header) is a candidate band-bg
regression. Add it to `BG_ALLOWLIST` only if the design genuinely
intends it to be opaque; otherwise fix the CSS.
5. **Diff against goldens.** If `diffs_vs_golden[].ratio` for any shot
exceeds your threshold (start at 0.02 = 2% pixels changed), open the
`*-diff.png` and confirm the change was intended.
6. **Run on prod after dev passes.** Same script, same expectations:
`bin/headless-test-v2.py https://arrflix.s8n.ru`. Dev mirror exists
(doc 12 / doc 17) precisely so you can verify there first.
7. **Only THEN write "verified" in the doc.** Always cite the run's
`probe.json` path and exit code in the verification note. Future-you
needs to be able to re-run the exact same gate.
Three single-sentence rules carved out of this protocol, for posters on
the wall:
- **Always test as both admin and non-admin** — admin-only sections are
invisible to guests, and a fix that breaks admin-only content will not
be detected by guest-only tests.
- **Always click Play** — page-load is necessary but not sufficient;
black-screen playback only manifests after `<video>` is instantiated
and a frame is requested.
- **Always sweep ALL backgrounds** — fixed-list selector probes only
catch regressions in selectors you already knew about, which is the
opposite of what a regression test is supposed to do.