ARRFLIX/bin/prod-vs-dev-compare.py

598 lines
25 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""ARRFLIX prod-vs-dev playback divergence test (2026-05-09).
Runs the SAME flow against arrflix.s8n.ru (prod) and dev.arrflix.s8n.ru (dev)
for the same physical file (Mike Nolan Show S01E04 Ding Dong Delli.mkv,
H.264+AAC) and produces a side-by-side diff:
- URL of master.m3u8 / Videos/{id}/stream
- PlaybackInfo response MediaSources[0] (DirectPlay/DirectStream/Transcode)
- Final <video> element state at t=5/10/20/30s after Play
- Server ffmpeg cmdline (if transcoding) from docker logs
- HTTP status of all /Videos /Items /master.m3u8 /PlaybackInfo /Audio
/stream requests
Artifacts: /tmp/arrflix-prod-vs-dev/{prod,dev}/{...} + diff.json + diff.md.
Run:
bin/prod-vs-dev-compare.py
"""
import sys, os, json, time, asyncio, ssl, urllib.request, urllib.error, urllib.parse, subprocess, re
from pathlib import Path
from playwright.async_api import async_playwright
OUT = "/tmp/arrflix-prod-vs-dev"
os.makedirs(OUT, exist_ok=True)
SIDES = [
{"side": "prod", "url": "https://arrflix.s8n.ru", "user": "s8n", "pw": "2001dude",
"container": "jellyfin"},
{"side": "dev", "url": "https://dev.arrflix.s8n.ru", "user": "test", "pw": "2001dude",
"container": "jellyfin-dev"},
]
ITEM_ID = "9312799ca24979bd05aad9733ce7ee14" # MNS S01E04 (same on both sides)
ITEM_LABEL = "Mike Nolan Show — S01E04 (Ding Dong Delli)"
DEVICE_ID = "prodvsdev-2026-05-09"
CLIENT = "ProdVsDev"
APIKEY_NAME = "arrflix-prodvsdev-2026-05-09"
CTX = ssl._create_unverified_context()
# ------------------- HTTP helpers -------------------
def auth_h(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="cli", DeviceId="{DEVICE_ID}", '
f'Version="1.0"')
if token:
h += f', Token="{token}"'
return h
def http(url, path, method="GET", body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": auth_h(token),
"Content-Type": "application/json",
}
req = urllib.request.Request(
f"{url}{path}", data=data, headers=headers, method=method)
raw = urllib.request.urlopen(req, context=CTX, timeout=20).read()
return json.loads(raw) if raw else {}
def login(url, user, pw):
last_err = None
for attempt in range(3):
try:
return http(url, "/Users/AuthenticateByName", "POST",
{"Username": user, "Pw": pw})
except urllib.error.HTTPError as e:
last_err = e
if e.code in (500, 503):
time.sleep(3); continue
raise
raise last_err
def playbackinfo(url, item_id, user_id, token):
"""Mimic the web-client's /PlaybackInfo POST body for a generic browser."""
body = {
"DeviceProfile": {
"MaxStreamingBitrate": 140000000,
"MaxStaticBitrate": 100000000,
"MusicStreamingTranscodingBitrate": 384000,
"DirectPlayProfiles": [
{"Container": "mp4,m4v", "Type": "Video",
"VideoCodec": "h264,hevc,vp9,av1",
"AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
{"Container": "mkv", "Type": "Video",
"VideoCodec": "h264,hevc,vp9,av1",
"AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
{"Container": "webm", "Type": "Video",
"VideoCodec": "vp9,av1", "AudioCodec": "opus,vorbis"},
],
"TranscodingProfiles": [
{"Container": "ts", "Type": "Video", "VideoCodec": "h264",
"AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming",
"MaxAudioChannels": "2"},
{"Container": "mp4", "Type": "Video", "VideoCodec": "h264",
"AudioCodec": "aac", "Context": "Static",
"MaxAudioChannels": "2"},
],
"ContainerProfiles": [],
"CodecProfiles": [],
"SubtitleProfiles": [
{"Format": "vtt", "Method": "External"},
{"Format": "srt", "Method": "External"},
],
},
"AutoOpenLiveStream": True,
"IsPlayback": True,
}
return http(url, f"/Items/{item_id}/PlaybackInfo?UserId={user_id}",
"POST", body, token=token)
def make_apikey(url, token, name=APIKEY_NAME):
"""Issue an API key. Jellyfin only takes the name in query string."""
try:
http(url, f"/Auth/Keys?App={name}", "POST", token=token)
except urllib.error.HTTPError:
pass
keys = http(url, "/Auth/Keys", token=token)
for k in keys.get("Items", []):
if k.get("AppName") == name:
return k.get("AccessToken")
return None
def del_apikey(url, token, name=APIKEY_NAME):
try:
keys = http(url, "/Auth/Keys", token=token)
for k in keys.get("Items", []):
if k.get("AppName") == name:
http(url, f"/Auth/Keys/{k['AccessToken']}", "DELETE", token=token)
except Exception as e:
print(f"[!] del_apikey({name}): {e}")
# ------------------- Playwright run -------------------
async def run_side(p, side_cfg):
side = side_cfg["side"]; url = side_cfg["url"]
user = side_cfg["user"]; pw = side_cfg["pw"]
side_dir = os.path.join(OUT, side)
os.makedirs(side_dir, exist_ok=True)
# API login
auth = login(url, user, pw)
token = auth["AccessToken"]; uid = auth["User"]["Id"]
server_id = auth["ServerId"]
is_admin = auth["User"].get("Policy", {}).get("IsAdministrator", False)
print(f"\n=== {side} === user={user} uid={uid} admin={is_admin}")
# API-side PlaybackInfo (independent of browser, for canonical record)
pbi_api = playbackinfo(url, ITEM_ID, uid, token)
with open(os.path.join(side_dir, "playbackinfo-api.json"), "w") as f:
json.dump(pbi_api, f, indent=2)
ms = pbi_api.get("MediaSources", [])
if ms:
m = ms[0]
print(f"[{side}] PlaybackInfo (API): DirectPlay={m.get('SupportsDirectPlay')} "
f"DirectStream={m.get('SupportsDirectStream')} "
f"Transcoding={m.get('SupportsTranscoding')} "
f"transcodeUrl={m.get('TranscodingUrl','-')[:80]}")
# API key for this run (caller asked, even if not strictly needed here)
apikey = make_apikey(url, token)
print(f"[{side}] api key: {apikey[:8] if apikey else None}")
# Browser pass
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage",
"--autoplay-policy=no-user-gesture-required",
"--use-fake-ui-for-media-stream"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
requests, responses, console = [], [], []
pbi_response_bodies = []
def on_request(req):
u = req.url
if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
"/PlaybackInfo", "/Audio/", "/stream"]):
requests.append({"method": req.method, "url": u,
"post": req.post_data[:300] if req.post_data else None})
page.on("request", on_request)
async def on_response(r):
u = r.url
if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
"/PlaybackInfo", "/Audio/", "/stream"]):
entry = {"method": r.request.method, "url": u, "status": r.status}
responses.append(entry)
if "/PlaybackInfo" in u and r.request.method == "POST":
try:
body = await r.json()
pbi_response_bodies.append({"url": u, "body": body})
except Exception:
pass
page.on("response", lambda r: asyncio.create_task(on_response(r)))
page.on("console", lambda m: console.append({"type": m.type,
"text": m.text[:300]}))
# Form login (handles both manual-form and user-avatar landing pages)
await page.goto(f"{url}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
# If we landed on the avatar/user-list selection screen, click "Manual Login"
try:
manual = await page.query_selector(".manualLoginForm a, .btnManual, a.button-link")
if manual:
txt = (await manual.inner_text()).strip().lower()
if "manual" in txt:
await manual.click()
await asyncio.sleep(2)
# Or there might be a direct "Manual Login" button on the avatar grid
manual_btn = await page.query_selector("text=/Manual Login/i")
if manual_btn:
try:
await manual_btn.click(timeout=2000); await asyncio.sleep(1)
except Exception:
pass
except Exception as e:
print(f"[{side}] manual-login click attempt: {e}")
try:
await page.wait_for_selector("input[type=password]", timeout=15000)
# Use the canonical Jellyfin login fields
u_sel = "#txtManualName"
pw_sel = "#txtManualPassword"
# Fall back to dynamic discovery if the canonical IDs are absent
if not await page.query_selector(u_sel):
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => "
"({id:i.id, name:i.name, type:i.type}))")
u_sel = pw_sel = None
for i in inputs:
fid, fname, ftype = i.get("id", ""), i.get("name", ""), i.get("type", "")
if not u_sel and (ftype == "text" or "user" in (fid+fname).lower()
or "name" in (fid+fname).lower()):
u_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if not pw_sel and ftype == "password":
pw_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
await page.fill(u_sel, user)
await page.fill(pw_sel, pw)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(3)
print(f"[{side}] form login OK as {user}")
except Exception as e:
print(f"[{side}] form login error: {e}")
# Navigate to detail page
target = f"{url}/web/#/details?id={ITEM_ID}&serverId={server_id}"
print(f"[{side}] goto {target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4)
await page.screenshot(path=os.path.join(side_dir, "detail.png"))
# Click Play
play_clicked = False
used_sel = None
for sel in [".btnPlay", "[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)
play_clicked = True; used_sel = sel; break
except Exception:
pass
if not play_clicked:
try:
await page.keyboard.press("p"); play_clicked = True; used_sel = "kbd:p"
except Exception:
pass
print(f"[{side}] play clicked={play_clicked} via={used_sel}")
# Sample state at t=5/10/20/30s
timestamps = [5, 10, 20, 30]
samples = []
last = 0
for t in timestamps:
await asyncio.sleep(t - last)
last = t
snap = await page.evaluate("""() => {
const v = document.querySelector('video');
if (!v) return { present: false };
// Sample whether the <video> is painting actual pixels by drawing
// a thumbnail to a hidden canvas and checking the average luma.
// If the average is ~0 (or all-near-zero), the video element is
// rendering opaque black despite claiming to play.
let paintLuma = null, paintRGBSum = null, paintOk = null, paintErr = null;
try {
const c = document.createElement('canvas');
c.width = 32; c.height = 18;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(v, 0, 0, 32, 18);
const d = ctx.getImageData(0, 0, 32, 18).data;
let r=0,g=0,b=0,n=0;
for (let i=0;i<d.length;i+=4){r+=d[i];g+=d[i+1];b+=d[i+2];n++;}
paintLuma = (0.299*r + 0.587*g + 0.114*b) / n;
paintRGBSum = (r+g+b)/n;
paintOk = paintLuma > 4; // > a few luma above pure black
} catch (e) { paintErr = String(e); }
return {
present: true,
src: v.src || '',
currentSrc: v.currentSrc || '',
currentTime: v.currentTime,
duration: v.duration,
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,
bufferedRanges: v.buffered.length,
bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0,
paintLuma, paintRGBSum, paintOk, paintErr,
};
}""")
samples.append({"t": t, "video": snap})
await page.screenshot(path=os.path.join(side_dir, f"play-t{t}.png"))
ct = snap.get('currentTime')
ct_s = f"{ct:.2f}" if isinstance(ct, (int, float)) else str(ct)
pl = snap.get('paintLuma')
pl_s = f"{pl:.1f}" if isinstance(pl, (int, float)) else str(pl)
print(f"[{side}] t={t}s: time={ct_s} "
f"paused={snap.get('paused')} err={snap.get('error')} "
f"dim={snap.get('videoWidth')}x{snap.get('videoHeight')} "
f"rs={snap.get('readyState')} paintLuma={pl_s} paintOk={snap.get('paintOk')}")
# Final src URL fully decoded
final_src = samples[-1]["video"].get("currentSrc") or samples[-1]["video"].get("src", "")
final_src_decoded = urllib.parse.unquote(final_src) if final_src else ""
await browser.close()
# Server side ffmpeg / transcode log
server_logs = ""
try:
server_logs = subprocess.check_output(
["ssh", "-o", "ConnectTimeout=5", "user@192.168.0.100",
f"docker logs --since 2m {side_cfg['container']} 2>&1 | tail -300"],
timeout=15).decode(errors="replace")
except Exception as e:
server_logs = f"(failed to fetch server logs: {e})"
# Extract ffmpeg cmdline + transcode reasons from log
ffmpeg_cmd = None
for line in server_logs.splitlines():
if "ffmpeg" in line.lower() and ("-i " in line or "-f hls" in line or "-c:v" in line):
ffmpeg_cmd = line.strip()
break
transcode_reasons = []
for line in server_logs.splitlines():
if "transcode reason" in line.lower() or "TranscodeReasons" in line:
transcode_reasons.append(line.strip())
# Save artifacts
side_out = {
"side": side, "url": url, "user": user, "uid": uid, "is_admin": is_admin,
"server_id": server_id, "item_id": ITEM_ID, "item_label": ITEM_LABEL,
"play_clicked": play_clicked, "play_selector": used_sel,
"samples": samples,
"final_src": final_src,
"final_src_decoded": final_src_decoded,
"playbackinfo_api": pbi_api,
"playbackinfo_browser_responses": pbi_response_bodies,
"requests": requests,
"responses": responses,
"console": console[-200:],
"ffmpeg_cmdline": ffmpeg_cmd,
"transcode_reasons_log": transcode_reasons,
}
with open(os.path.join(side_dir, "result.json"), "w") as f:
json.dump(side_out, f, indent=2, default=str)
with open(os.path.join(side_dir, "server.log"), "w") as f:
f.write(server_logs)
# Cleanup the temp api key
del_apikey(url, token)
return side_out
# ------------------- Diff & report -------------------
def diff_results(prod, dev):
"""Build the comparison matrix."""
def keyfields(pbi):
ms = pbi.get("MediaSources", [])
if not ms:
return None
m = ms[0]
return {
"Container": m.get("Container"),
"Protocol": m.get("Protocol"),
"SupportsDirectPlay": m.get("SupportsDirectPlay"),
"SupportsDirectStream": m.get("SupportsDirectStream"),
"SupportsTranscoding": m.get("SupportsTranscoding"),
"TranscodingUrl": m.get("TranscodingUrl"),
"TranscodingSubProtocol": m.get("TranscodingSubProtocol"),
"TranscodingContainer": m.get("TranscodingContainer"),
"TranscodeReasons": m.get("TranscodeReasons"),
"Bitrate": m.get("Bitrate"),
"Size": m.get("Size"),
"Path": m.get("Path"),
}
p_pbi = keyfields(prod["playbackinfo_api"])
d_pbi = keyfields(dev["playbackinfo_api"])
last_p = prod["samples"][-1]["video"]
last_d = dev["samples"][-1]["video"]
out = {
"item_id": ITEM_ID, "label": ITEM_LABEL,
"prod_url": prod["url"], "dev_url": dev["url"],
"playback_info_diff": {
"prod": p_pbi, "dev": d_pbi,
"differences": {
k: {"prod": p_pbi.get(k), "dev": d_pbi.get(k)}
for k in (set(p_pbi or {}) | set(d_pbi or {}))
if (p_pbi or {}).get(k) != (d_pbi or {}).get(k)
} if p_pbi and d_pbi else "missing-on-one-side",
},
"video_state_t30": {
"prod": last_p,
"dev": last_d,
"differences": {
k: {"prod": last_p.get(k), "dev": last_d.get(k)}
for k in (set(last_p) | set(last_d))
if last_p.get(k) != last_d.get(k)
},
},
"stream_url_prod": prod.get("final_src_decoded"),
"stream_url_dev": dev.get("final_src_decoded"),
"ffmpeg_cmdline_prod": prod.get("ffmpeg_cmdline"),
"ffmpeg_cmdline_dev": dev.get("ffmpeg_cmdline"),
"transcode_reasons_log_prod": prod.get("transcode_reasons_log"),
"transcode_reasons_log_dev": dev.get("transcode_reasons_log"),
"http_status_diff": [],
}
# HTTP-status diff: for matched URL templates, show statuses where they differ.
def normalise(u):
# Strip /Videos/{id} → /Videos/* and quoting; keep last path segment
u = re.sub(r"/Videos/[a-f0-9]{32}", "/Videos/*", u)
u = re.sub(r"/Items/[a-f0-9]{32}", "/Items/*", u)
u = re.sub(r"\?.*$", "", u)
u = re.sub(r"^https?://[^/]+", "", u)
return u
def status_map(rs):
out = {}
for r in rs:
k = (r["method"], normalise(r["url"]))
out.setdefault(k, []).append(r["status"])
return out
sp = status_map(prod.get("responses", []))
sd = status_map(dev.get("responses", []))
keys = set(sp) | set(sd)
for k in sorted(keys):
if sp.get(k) != sd.get(k):
out["http_status_diff"].append({
"method": k[0], "path": k[1],
"prod": sp.get(k), "dev": sd.get(k),
})
return out
def render_md(diff, prod, dev):
pp = diff["playback_info_diff"].get("prod") or {}
dp = diff["playback_info_diff"].get("dev") or {}
last_p = diff["video_state_t30"]["prod"]
last_d = diff["video_state_t30"]["dev"]
def fmt_bool(x): return "Y" if x else ("N" if x is False else "")
def headline():
# Three failure modes to recognise, in order:
# 1. paused-at-zero → MediaSource attach never fired
# 2. <video>.error → format/decode error
# 3. paint-black → video advances but renders no pixels (DRM-style
# black, or codec-not-actually-decodable in this
# chromium build despite advancing the clock)
bp = bool(last_p.get("paused")) and (last_p.get("currentTime", 0) or 0) < 0.1
bd = bool(last_d.get("paused")) and (last_d.get("currentTime", 0) or 0) < 0.1
if bp and not bd:
return ("prod fails because video stayed paused at t=0 while dev advanced")
if bd and not bp:
return ("dev fails because video stayed paused at t=0 while prod advanced")
if last_p.get("error") and not last_d.get("error"):
return f"prod fails because <video>.error code={last_p['error'].get('code')}"
if last_d.get("error") and not last_p.get("error"):
return f"dev fails because <video>.error code={last_d['error'].get('code')}"
# Paint check
pp_ok = last_p.get("paintOk"); dp_ok = last_d.get("paintOk")
if pp_ok is False and dp_ok is True:
return ("prod fails because <video> advances time but paints all-black "
"(paintLuma~0) while dev paints normally — pixels never reach the canvas")
if dp_ok is False and pp_ok is True:
return ("dev fails because <video> advances time but paints all-black "
"(paintLuma~0) while prod paints normally")
return "neither side errored or painted black explicitly — see HTTP/PlaybackInfo/cmdline diffs"
md = []
md.append(f"# Prod vs Dev — playback divergence test ({time.strftime('%Y-%m-%d %H:%M')})")
md.append("")
md.append(f"Item: **{diff['label']}** (ItemId `{diff['item_id']}`)")
md.append("")
md.append(f"**Headline:** {headline()}")
md.append("")
md.append("## Final video state at t=30s")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["present", "currentTime", "duration", "paused", "ended",
"readyState", "networkState", "error",
"videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd",
"paintLuma", "paintRGBSum", "paintOk"]:
md.append(f"| {k} | `{last_p.get(k)}` | `{last_d.get(k)}` |")
md.append("")
md.append("## Stream URL (decoded)")
md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`")
md.append(f"- **dev**: `{diff.get('stream_url_dev') or '(empty)'}`")
md.append("")
md.append("## PlaybackInfo MediaSources[0]")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["Container", "Protocol", "SupportsDirectPlay",
"SupportsDirectStream", "SupportsTranscoding",
"TranscodingUrl", "TranscodingSubProtocol", "TranscodingContainer",
"TranscodeReasons", "Bitrate", "Size", "Path"]:
md.append(f"| {k} | `{pp.get(k)}` | `{dp.get(k)}` |")
md.append("")
md.append("## ffmpeg cmdline (from docker logs)")
md.append(f"- **prod**: `{diff.get('ffmpeg_cmdline_prod') or '(none — no transcoding observed)'}`")
md.append(f"- **dev**: `{diff.get('ffmpeg_cmdline_dev') or '(none — no transcoding observed)'}`")
md.append("")
md.append("## HTTP status differences")
if diff.get("http_status_diff"):
md.append("| Method | Path | prod | dev |")
md.append("|---|---|---|---|")
for r in diff["http_status_diff"]:
md.append(f"| {r['method']} | `{r['path']}` | {r['prod']} | {r['dev']} |")
else:
md.append("(none — all matched URLs returned the same status code)")
md.append("")
md.append("## Per-sample timeline")
md.append("| t | prod time | prod paused | prod err | dev time | dev paused | dev err |")
md.append("|---|---|---|---|---|---|---|")
for ps, ds in zip(prod["samples"], dev["samples"]):
pv, dv = ps["video"], ds["video"]
md.append(f"| {ps['t']}s | {pv.get('currentTime')} | {pv.get('paused')} | "
f"{pv.get('error')} | {dv.get('currentTime')} | {dv.get('paused')} | "
f"{dv.get('error')} |")
md.append("")
return "\n".join(md)
# ------------------- main -------------------
async def main():
print(f"[+] OUT: {OUT}")
async with async_playwright() as p:
prod = await run_side(p, SIDES[0])
dev = await run_side(p, SIDES[1])
diff = diff_results(prod, dev)
with open(os.path.join(OUT, "diff.json"), "w") as f:
json.dump(diff, f, indent=2, default=str)
md = render_md(diff, prod, dev)
with open(os.path.join(OUT, "diff.md"), "w") as f:
f.write(md)
print("\n=== SUMMARY ===")
last_p = diff["video_state_t30"]["prod"]; last_d = diff["video_state_t30"]["dev"]
print(f"prod t=30: time={last_p.get('currentTime')} paused={last_p.get('paused')} "
f"err={last_p.get('error')} dim={last_p.get('videoWidth')}x{last_p.get('videoHeight')}")
print(f"dev t=30: time={last_d.get('currentTime')} paused={last_d.get('paused')} "
f"err={last_d.get('error')} dim={last_d.get('videoWidth')}x{last_d.get('videoHeight')}")
print(f"diff.json: {os.path.join(OUT, 'diff.json')}")
print(f"diff.md: {os.path.join(OUT, 'diff.md')}")
if __name__ == "__main__":
asyncio.run(main())