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.
9.1 KiB
HEADLESS-PROBE — playwright + DOM recipes
Copy-paste these to verify any theme/playback change. All use
mcr.microsoft.com/playwright/python:v1.49.0-noblewith--userns=host --network container:jellyfin-dev(orjellyfinfor prod).
Setup (one-time per session)
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.
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:
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.
() => {
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).
() => {
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)
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
() => {
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)
(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.
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:
# (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)
# 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
() => 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)
() => {
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).