7 docs in /testing/ — institutional memory after 6+ regressions in
24-48h on the v6 theme. Read before any edit.
README.md — index + quickstart
THEMING.md — safe-edit checklist + layer/specificity tables
ERROR-PATTERNS.md — 12 cataloged patterns (Symptom/Cause/Diag/Fix/Prev)
HEADLESS-PROBE.md — 11 playwright recipes (md5 chain, darkPct,
ancestor bg sample, dropdown listItem probe)
ROLLBACK.md — 8 emergency revert recipes (overlay, branding,
encoding, full-from-repo, dev-clone-prod,
git-revert, pw-reset, bind-mount inode-swap)
SMOKE-TEST.md — manual + headless verify checklist
DEPLOY.md — dev → prod promotion workflow with backup +
chown root + restart inode-swap
Empty subdirs: snipUSER-Es/, recipes/, incidents/ (post-mortems land here).
Goal: stop reinventing the same fixes. Catalog every error class,
codify the recovery, build a skills folder for future ARRFLIX work.
209 lines
9.1 KiB
Markdown
209 lines
9.1 KiB
Markdown
# 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).
|