# HEADLESS-PROBE — playwright + DOM recipes

> Copy-paste these to verify any theme/playback change. All use `mcr.microsoft.com/playwright/python:v1.49.0-noble` with `--userns=host --network container:jellyfin-dev` (or `jellyfin` for prod).

## Setup (one-time per session)

```bash
ssh user@192.168.0.100 'docker pull mcr.microsoft.com/playwright/python:v1.49.0-noble' >/dev/null
mkdir -p /tmp/arrflix-probes
# Run pattern (on nullstone):
docker run --rm --userns=host --network container:jellyfin-dev \
  -v /tmp/arrflix-probes:/out -v /tmp/probe-X.py:/probe.py:ro \
  mcr.microsoft.com/playwright/python:v1.49.0-noble python /probe.py
```

## RECIPE 1 — auth + pre-seed credentials

Boilerplate every recipe imports. `/Users/AuthenticateByName` returns `{AccessToken, User.Id, ServerId}`. Jellyfin web reads `localStorage['jellyfin_credentials']` on boot — pre-seeding via `add_init_script` skips login.

```python
import asyncio, json, urllib.request
from playwright.async_api import async_playwright
URL='http://127.0.0.1:8096'
USER,PW='test','123'
def auth():
    req=urllib.request.Request(f"{URL}/Users/AuthenticateByName",
        data=json.dumps({"Username":USER,"Pw":PW}).encode(),
        headers={"Content-Type":"application/json","Authorization":'MediaBrowser Client="probe", Device="x", DeviceId="probe-1", Version="1.0"'},method="POST")
    return json.loads(urllib.request.urlopen(req,timeout=15).read())
a=auth(); token=a["AccessToken"]; uid=a["User"]["Id"]; sid=a["ServerId"]
```

Pre-seed creds via `add_init_script`:

```python
await page.add_init_script(f"""
  localStorage.setItem('jellyfin_credentials', JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}));
""")
```

If auth returns 401 + `sqlite-readonly` in `docker logs jellyfin-dev`, test password got nuked. Recovery: `docker exec jellyfin-dev sqlite3 /config/data/jellyfin.db "UPDATE Users SET Password=NULL,EasyPassword=NULL WHERE Username='test'"` then `docker restart jellyfin-dev` and POST `/Users/{uid}/Password` with `{NewPw:"123"}`.

## RECIPE 2 — bg-color of every ancestor of `<video>`

Tests L1/L2 transparent rules. Run during playback.

```js
() => {
  const v = document.querySelector('video.htmlvideoplayer'); if (!v) return {found:false};
  const chain=[];
  for (let el=v; el; el=el.parentElement) {
    chain.push({tag:el.tagName, cls:String(el.className).slice(0,80), id:el.id, bg:getComputedStyle(el).backgroundColor, z:getComputedStyle(el).zIndex});
  }
  return {found:true, chain};
}
```

Expect every ancestor `rgba(0,0,0,0)` except `<html>` = `rgb(0,0,0)`.

## RECIPE 3 — darkPct on rendered viewport

Detects "video decodes but is visually black" (doc 28 INC7).

```js
() => {
  const v = document.querySelector('video.htmlvideoplayer'); if (!v) return null;
  const c = document.createElement('canvas'); c.width=320; c.height=180;
  const ctx = c.getContext('2d'); ctx.drawImage(v, 0, 0, 320, 180);
  const data = ctx.getImageData(0, 0, 320, 180).data;
  let dark=0, total=320*180;
  for (let i=0; i<data.length; i+=4) {
    const max = Math.max(data[i], data[i+1], data[i+2]);
    if (max < 32) dark++;
  }
  return {darkPct: dark/total, currentTime:v.currentTime, videoWidth:v.videoWidth};
}
```

Expect `darkPct < 0.2` during playback. `> 0.7` = black overlay.

## RECIPE 4 — md5 chain (host → container → served)

```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev curl -s http://127.0.0.1:8096/web/index.html | md5sum'
```

All three must match. If host ≠ container: bind-mount inode swap (ERROR-PATTERNS#3) — `docker restart jellyfin-dev`.

## RECIPE 5 — computed style of selector

```js
() => {
  const el = document.querySelector('TARGET-SELECTOR-HERE');
  if (!el) return {found:false};
  const cs = getComputedStyle(el);
  const props = ['backgroundColor', 'color', 'outline', 'border', 'zIndex', 'display', 'visibility'];
  return {found:true, computed:Object.fromEntries(props.map(p=>[p, cs[p]]))};
}
```

## RECIPE 6 — dump CSS rules matching selector token (cascade debug)

```js
(token) => {
  const rules=[];
  for (const s of document.styleSheets) {
    try { for (const r of s.cssRules) {
      if (r.style && r.selectorText && r.selectorText.indexOf(token)>=0)
        rules.push({sel:r.selectorText.slice(0,180), css:r.style.cssText.slice(0,200), src:(s.href||'inline').slice(-80)});
    }} catch(e){ rules.push({err:String(e).slice(0,40), src:s.href}); }
  }
  return rules; // later sheets override earlier; arrflix overrides should appear last
}
// page.evaluate("(t)=>{...}", "htmlVideoPlayer")
```

## RECIPE 7 — open dropdown, sample selected listItem

Audio/subtitle picker theme check.

```js
async () => {
  const btn = document.querySelector('.btnAudio, .audioMenuButton'); btn?.click();
  await new Promise(r => setTimeout(r, 600));
  const sel = document.querySelector('.actionSheet .listItem.selected, .actionSheet .listItem-button.selected, .actionSheet .listItem.focused');
  if (!sel) return {found:false};
  const cs = getComputedStyle(sel);
  return {found:true, computed:{outline:cs.outline, bg:cs.backgroundColor, color:cs.color}};
}
```

## RECIPE 8 — full playback smoke (auth + play + sample @5/10/15s)

Reuses Recipe 1 boilerplate. Save as `testing/recipes/smoke-playback.py`:

```python
# (prelude from Recipe 1: auth(), token/uid/sid)
ITEM='324f75b84f394a5d9b0749c0679f23b9'
SAMPLE = """()=>{const v=document.querySelector('video.htmlvideoplayer');
  if(!v)return{hasVideo:false};
  const c=document.createElement('canvas');c.width=320;c.height=180;
  const x=c.getContext('2d');x.drawImage(v,0,0,320,180);
  const d=x.getImageData(0,0,320,180).data;let dark=0;
  for(let i=0;i<d.length;i+=4)if(Math.max(d[i],d[i+1],d[i+2])<32)dark++;
  return{hasVideo:true,t:v.currentTime,w:v.videoWidth,darkPct:dark/57600};}"""
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch(headless=True,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
        page=await (await b.new_context(viewport={"width":1280,"height":720})).new_page()
        await page.add_init_script(f"localStorage.setItem('jellyfin_credentials',JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}))")
        await page.goto(f"{URL}/web/index.html#/details?id={ITEM}",wait_until="networkidle",timeout=30000)
        await page.wait_for_timeout(4000)
        try: await page.click('button.btnPlay,.mainDetailButtons .btnPlay,button[title="Play"]',timeout=5000)
        except: pass
        out=[]
        for t in (5,10,15):
            await page.wait_for_timeout(5000)
            out.append({"at_s":t, **(await page.evaluate(SAMPLE))})
        await page.screenshot(path="/out/smoke.png"); print("SMOKE",json.dumps(out,indent=1))
        await b.close()
asyncio.run(main())
```

Pass: all three samples `hasVideo:true`, `t` advancing, `darkPct < 0.2`.

## RECIPE 9 — compare two overlays (visual diff)

```bash
# Nullstone: swap+render each, scp screenshots back.
for V in baseline candidate; do
  ssh user@nullstone "docker cp /tmp/$V-index.html jellyfin-dev:/jellyfin/jellyfin-web/index.html && docker restart jellyfin-dev"; sleep 8
  ssh user@nullstone "docker run --rm --userns=host --network container:jellyfin-dev -v /tmp/probe-real.py:/p.py:ro -v /tmp/arrflix-probes:/out mcr.microsoft.com/playwright/python:v1.49.0-noble python /p.py"
  scp user@nullstone:/tmp/arrflix-probes/dev-real-vid.png /tmp/$V.png
done
# Onyx: Pillow diff
python -c "from PIL import Image,ImageChops as C
a=Image.open('/tmp/baseline.png').convert('RGB');b=Image.open('/tmp/candidate.png').convert('RGB')
d=C.difference(a,b);h=d.histogram();print(f'changed={sum(h[1:256])+sum(h[257:512])+sum(h[513:768])} bbox={d.getbbox()}');d.save('/tmp/overlay-diff.png')"
```

## RECIPE 10 — favicon shim (lockFavicon) verify

```js
() => Array.from(document.querySelectorAll('link[rel*="icon"]')).map(l => ({
  rel:l.rel, sizes:l.sizes?.value, dataAttr:l.getAttribute('data-arrflix-icon'),
  isArrflixA:l.href.indexOf('iVBORw0KGgoAAAANSUhEUgAAAIo')>0,
  hrefHead:l.href.slice(0,80)
}))
```

Expect `dataAttr:"1"` + arrflix base64 prefix on every icon link after ~5s (poll interval).

## RECIPE 11 — force `arrflix-video-active` (theme isolation, no real playback)

```js
() => {
  const v=document.createElement('div'); v.className='htmlVideoPlayer';
  v.style.cssText='position:fixed;inset:0;z-index:5;'; document.body.appendChild(v);
  document.body.classList.add('arrflix-video-active');
  const els=['body','#reactRoot','.skinBody','.backgroundContainer','.mainAnimatedPages','.pageContainer','.videoPlayerContainer','.htmlVideoPlayer'];
  return Object.fromEntries(els.map(s=>{const el=document.querySelector(s); return [s, el?{bg:getComputedStyle(el).backgroundColor,z:getComputedStyle(el).zIndex}:null];}));
}
```

## Where to store probe scripts

`testing/snipUSER-Es/` — one-liners + bash. `testing/recipes/` — full python (recipes 1, 8, 9 live here).
