diff --git a/discover.py b/discover.py new file mode 100644 index 0000000..32c6375 --- /dev/null +++ b/discover.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Drive the player with Playwright and capture the traffic that mints a token. + +Purpose: find which request produces a stream URL, so the mint can eventually +run without a GUI. Playwright sees request AND response bodies natively, so +this replaces the userscript's hooks with something far less fragile. + +No password is needed. Your existing Firefox session cookies are injected into +a fresh browser context, so this rides the login you already have. + + python discover.py --url "" + python discover.py --url "..." --headless + +Ports to Camoufox on halogen by swapping the launcher: Camoufox speaks the +Playwright protocol, so the flow below is unchanged. +""" +import argparse +import json +import os +import re +import sys +from datetime import datetime + +ROOT = os.path.dirname(os.path.abspath(__file__)) +LOGS = os.path.join(ROOT, "logs") +TRACE = os.path.join(LOGS, "pw-trace.jsonl") + +# Only these domains' cookies are handed to the browser. Everything else in +# the Firefox jar is irrelevant here and should not be copied around. +COOKIE_DOMAINS = ("bristolrovers", "streamamg", "kaltura", "akamaized") + +# Bodies we never want to read: media segments would be dumped into the log. +SKIP_BODY = (".ts", ".mp4", ".m4s", ".aac", ".jpg", ".jpeg", ".png", ".gif", + ".webp", ".svg", ".woff", ".woff2", ".css", ".ico") + +MAX_BODY = 20000 + + +def firefox_cookies(): + """Lift the live session from Firefox. Requires no password.""" + from yt_dlp.cookies import extract_cookies_from_browser + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stderr(buf): + jar = extract_cookies_from_browser("firefox") + + out = [] + for c in jar: + dom = (c.domain or "") + if not any(k in dom.lower() for k in COOKIE_DOMAINS): + continue + out.append({ + "name": c.name, + "value": c.value or "", + "domain": dom, + "path": c.path or "/", + "expires": float(c.expires) if c.expires else -1, + "httpOnly": False, + "secure": bool(c.secure), + "sameSite": "Lax", + }) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--url", required=True, help="match page URL") + ap.add_argument("--headless", action="store_true", + help="run without a window (what halogen will do)") + ap.add_argument("--seconds", type=int, default=90, + help="how long to watch after load") + args = ap.parse_args() + + try: + from playwright.sync_api import sync_playwright + except ImportError: + sys.exit("playwright not installed: pip install playwright " + "&& python -m playwright install firefox") + + os.makedirs(LOGS, exist_ok=True) + cookies = firefox_cookies() + print(f"injecting {len(cookies)} session cookies") + + manifests, mints, jwts, seen = [], [], [], set() + fh = open(TRACE, "a", encoding="utf-8") + + def write(rec): + rec["ts"] = datetime.now().isoformat() + fh.write(json.dumps(rec, ensure_ascii=False) + chr(10)) + fh.flush() + + with sync_playwright() as p: + browser = p.firefox.launch(headless=args.headless) + ctx = browser.new_context( + user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) " + "Gecko/20100101 Firefox/131.0"), + viewport={"width": 1440, "height": 900}, + ) + if cookies: + try: + ctx.add_cookies(cookies) + except Exception as exc: + print(f"cookie injection failed: {exc}") + + page = ctx.new_page() + + def on_response(resp): + url = resp.url + if url in seen: + return + low = url.split("?")[0].lower() + if ".m3u8" in low: + seen.add(url) + manifests.append(url) + print(f" manifest: {url[:120]}") + write({"kind": "manifest", "url": url, "status": resp.status}) + return + if any(low.endswith(e) for e in SKIP_BODY): + return + try: + body = resp.text() + except Exception: + return + if not body: + return + # The JWT that authorises the mint is issued somewhere on the club + # site off the session cookie. Detect it the same way: by output. + # A JWT is three base64 segments starting with a b64 '{"alg"'. + if re.search(r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.", body): + seen.add(url) + req = resp.request + rec = { + "kind": "JWT-ISSUER", + "method": req.method, + "url": url, + "status": resp.status, + "reqHeaders": dict(req.headers), + "reqBody": (req.post_data or None), + "body": body[:MAX_BODY], + } + jwts.append(rec) + write(rec) + print(f" *** JWT ISSUER *** {req.method} {url[:100]}") + return + + # The mint call is whatever RETURNS a manifest URL, whatever it is + # named. Detect by output, not by guessing endpoint names. + if "m3u8" in body.lower(): + seen.add(url) + req = resp.request + rec = { + "kind": "MINT", + "method": req.method, + "url": url, + "status": resp.status, + "reqHeaders": dict(req.headers), + "reqBody": (req.post_data or None), + "body": body[:MAX_BODY], + } + mints.append(rec) + write(rec) + print(f" *** MINT *** {req.method} {url[:110]}") + + page.on("response", on_response) + + print(f"loading {args.url}") + page.goto(args.url, wait_until="domcontentloaded", timeout=60000) + + # The player needs a click. Muted playback is enough to trigger the + # manifest fetch, and muted autoplay is not blocked. + page.wait_for_timeout(5000) + for sel in (".bmpui-ui-hugeplaybacktogglebutton", + ".bmpui-ui-playbacktogglebutton", + "button[aria-label*='play' i]"): + try: + el = page.query_selector(sel) + if el and el.is_visible(): + el.click() + print(f"clicked {sel}") + break + except Exception: + pass + try: + page.evaluate("() => { const v = document.querySelector('video');" + " if (v) { v.muted = true; v.play(); } }") + except Exception: + pass + + print(f"watching for {args.seconds}s ...") + page.wait_for_timeout(args.seconds * 1000) + browser.close() + + fh.close() + print(f"\nmanifests seen : {len(manifests)}") + print(f"mint calls : {len(mints)}") + for m in mints: + print(f"\n {m['method']} {m['url']}") + print(f" status {m['status']} reqBody={str(m['reqBody'])[:200]}") + print(f"\nfull trace -> {TRACE}") + + +if __name__ == "__main__": + main() diff --git a/tampermonkey/brfc-stream-sniffer.user.js b/tampermonkey/brfc-stream-sniffer.user.js index 36efb1e..78c42d1 100644 --- a/tampermonkey/brfc-stream-sniffer.user.js +++ b/tampermonkey/brfc-stream-sniffer.user.js @@ -27,6 +27,13 @@ // once the endpoint is known -- it writes a lot. const TRACE = true; + // Start playback without a human. The player needs a click, but the browser + // only has to trigger the manifest fetch -- ffmpeg records the real audio + // independently, so playing MUTED costs nothing and muted autoplay is + // permitted by Firefox policy where audible autoplay is not. + const AUTOPLAY = true; + const AUTOPLAY_TIMEOUT_MS = 180000; // pre-match margin is long; keep trying + const IDLE_OPACITY = 0.063; // 16/255 const MAX_BODY = 20000; // never log a whole media segment @@ -188,4 +195,67 @@ panel.appendChild(row); } } + + // --- autoplay ------------------------------------------------------------- + // Runs in whichever frame owns the