Capture Bristol Rovers video pass streams for personal time-shifting

Verified end to end against a live match: 1080p H.264/AAC MPEG-TS at
~5.2 Mbit/s, recorded while the browser played the same stream.

Components:
- tampermonkey sniffer hooks fetch/XHR at document-start, captures the
  manifest URL verbatim and POSTs it to the local catcher. v0.4 also traces
  the API chain to find which call mints the token.
- catcher receives the URL and launches yt-dlp. Dedupes on the Kaltura asset
  id and checks downloader liveness, so a mid-match page reload is both safe
  and the recovery path for a dead downloader.
- creds stores secrets in the OS vault rather than a gitignored file, and
  refuses to run on a backend that does not encrypt.

Protocol notes are in the README: the stream uses a two-stage token chain
(a ~25s hdnts token for the master playlist, a ~24h hdntl token for media),
concurrency is unrestricted, and the live window is ~30s with no DVR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
This commit is contained in:
Mikkeli Matlock
2026-08-29 23:57:05 +09:00
commit 21113bd6ba
6 changed files with 711 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
recordings/
logs/
stream-url.txt
cookies.txt
*.mkv
*.mp4
*.ts
+111
View File
@@ -0,0 +1,111 @@
# brfc-stream-pirate
Personal time-shifting archive for Bristol Rovers video pass streams.
Kickoffs land at unfriendly hours in JST; this records them unattended.
Stream is **plain HLS, no DRM** (StreamAMG behind Akamai). Nothing here breaks
a protection measure -- it replays the same authenticated request the player
makes, using your own paid session.
## Pieces
| File | Job |
|---|---|
| `tampermonkey/brfc-stream-sniffer.user.js` | Hooks fetch/XHR in the page, captures the manifest URL verbatim, POSTs it to the catcher |
| `catcher.py` | Listens on 127.0.0.1:8787, hands the URL to yt-dlp, records to `recordings/` |
| `probe.py` | Byte-exact manifest fetch + concurrency test |
## The token
Manifest URLs carry an Akamai path token:
hdntl=exp=<unix>~acl=/*~id=<n>~data=hdntl~hmac=<sig>
- `exp` -- ~24h validity
- `acl=/*` -- wildcard over the whole host, not just your match
- `hmac` -- signature over the literal path
It is a bearer credential. Anyone holding a live URL can pull the stream until
`exp`. Keep it out of pastebins and issue trackers. `stream-url.txt` and
`cookies.txt` are gitignored.
**Do not re-encode the path.** curl normalises `%2f` to `%2F`, which
invalidates the HMAC. `probe.py` sends the request target verbatim.
## Use Firefox for watching
yt-dlp lifts session cookies from the browser. Chrome 127+ on Windows uses
app-bound cookie encryption and extraction fails. Firefox works with no fuss.
set BRFC_BROWSER=firefox # default
## Workflow
pip install yt-dlp # done
python catcher.py # leave running
Install the userscript in Tampermonkey, open the match page, press play. The
sniffer catches the manifest, the catcher starts yt-dlp. Recording is MPEG-TS
(`--hls-use-mpegts`) so a killed process still leaves a playable file.
For a 3am kickoff: Task Scheduler opens the match page a few minutes before,
the browser autoplays, the sniffer fires, the catcher records. Nobody is awake.
## Concurrency check
An Akamai edge token is a stateless HMAC -- the edge validates signature and
expiry and serves; it holds no session table, so it cannot count viewers. Any
concurrent-stream limit lives in the entitlement API that *issues* tokens,
upstream of the CDN. Confirm rather than assume, while the browser is playing:
python probe.py --url "<manifest from the sniffer>" --parallel 3
3/3 alongside the playing browser means four concurrent readers of one token
and the edge does not care.
---
## Findings (2026-08-29, live 2300 JST match)
Verified against a live stream. Recorded 1080p successfully end to end.
### Two token types, not one
The player uses a **two-stage token chain**:
| Token | Where | TTL | Purpose |
|---|---|---|---|
| `hdnts=` | query string, `acl=/*` unencoded | **~25 seconds** | fetch the master playlist |
| `hdntl=` | URL path, `acl=%2f*` encoded | **~24 hours** | media playlists + segments |
The master response rewrites each variant URL with a 24h `hdntl` token. So the
`hdntl` token is the durable one worth handing to a downloader. Chasing a
`hdnts` URL by hand is pointless -- it dies before you can paste it.
### Confirmed by experiment
- **Concurrency is not limited.** Two independent ffmpeg downloads ran at full
bitrate *while Firefox played the same match*. Three concurrent readers of
one token, no complaint from the edge. Akamai token auth is a stateless HMAC
check; it has no viewer counter.
- **No CDN cookies exist.** All 13 relevant cookies are on `bristolrovers.co.uk`;
nothing is scoped to `akamaized.net`. The token in the URL is the whole auth.
- **Stream is 1920x1080 H.264 + AAC ~5.2 Mbit/s** -> roughly 4.7 GB per match.
- **Output is MPEG-TS** even though yt-dlp names it `.mp4`. That is what
`--hls-use-mpegts` buys: a killed process still leaves a playable file.
- Player is **Bitmovin**, embedded on the club site. `0_xm5vrrw6` is a Kaltura
entry id and identifies the match -- the catcher dedupes on it.
### Gotcha: use ffmpeg/yt-dlp, not a hand-rolled HTTP client
Python's `http.client` gets a **403 on every request**, with any header set,
including ffmpeg's exact headers, on an unexpired token from the same IP. The
difference is below HTTP (TLS handshake). ffmpeg fetches the same URL fine, so
just use yt-dlp. `probe.py` is diagnostic scaffolding and inherits this 403 --
do not use it to conclude a token is dead.
### Firefox, not Chrome
yt-dlp reads cookies from a running Firefox. Chrome/Edge lock their cookie DB
while running (yt-dlp issue 7271), and an unattended job cannot close the
browser to read them.
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Receive a manifest URL from the Tampermonkey sniffer and record the match.
Runs on localhost only. The userscript POSTs whatever manifest URL the player
actually requested; we hand it to yt-dlp.
Reload resilience is the whole point of the liveness check below. A page
reload mid-match is expected (network blips, tab crashes), and it must be
*safe* -- it must not start a duplicate recording, and it must restart a
recording that has died. So we dedupe on "is a downloader for this match
still alive", not "have I seen this match before".
"""
import json
import os
import re
import subprocess
import sys
import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
HOST, PORT = "127.0.0.1", 8787
ROOT = os.path.dirname(os.path.abspath(__file__))
RECORDINGS = os.path.join(ROOT, "recordings")
LOGS = os.path.join(ROOT, "logs")
# yt-dlp lifts session cookies from here. Firefox is required on Windows:
# Chrome/Edge lock their cookie DB while running (yt-dlp issue 7271).
BROWSER = os.environ.get("BRFC_BROWSER", "firefox")
# Hard stop, so an unattended run cannot record forever if the stream never
# ends cleanly. Pad past 90 min for half-time, stoppage and post-match.
DURATION_MIN = int(os.environ.get("BRFC_DURATION_MIN", "150"))
# Kaltura entry id (e.g. 0_xm5vrrw6) identifies the match. Every manifest for
# one game -- master and each variant, each with its own token -- shares it.
ASSET_RE = re.compile(r"/(\d+_[a-z0-9]+)/", re.I)
_procs = {} # asset key -> Popen of the running downloader
_lock = threading.Lock()
def log(msg):
line = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}"
print(line, flush=True)
os.makedirs(LOGS, exist_ok=True)
with open(os.path.join(LOGS, "catcher.log"), "a", encoding="utf-8") as fh:
fh.write(line + "\n")
def asset_key(url):
m = ASSET_RE.search(url)
return m.group(1) if m else url
def start_recording(key, url, user_agent, page):
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = os.path.join(RECORDINGS, f"brfc-{key}-{stamp}.ts")
os.makedirs(RECORDINGS, exist_ok=True)
cmd = [
sys.executable, "-m", "yt_dlp",
"--hls-use-mpegts", # truncated file still plays
"--no-part",
"--cookies-from-browser", BROWSER,
"--user-agent", user_agent or "Mozilla/5.0",
"--referer", page or "https://www.bristolrovers.co.uk/",
# yt-dlp's own retry flags do NOT reach the ffmpeg downloader, which is
# what actually pulls live HLS. These are ffmpeg's own reconnect knobs.
"--downloader-args",
"ffmpeg_i:-reconnect 1 -reconnect_streamed 1 "
"-reconnect_on_network_error 1 -reconnect_delay_max 30",
"--downloader-args", f"ffmpeg_o:-t {DURATION_MIN * 60}",
"-o", out,
url,
]
logfile = os.path.join(LOGS, f"yt-dlp-{stamp}.log")
fh = open(logfile, "w", encoding="utf-8")
proc = subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT)
log(f"START {key} -> {os.path.basename(out)} (pid {proc.pid}, cap {DURATION_MIN}min)")
return proc
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path not in ("/stream", "/trace"):
self.send_error(404)
return
n = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(n) or b"{}")
except json.JSONDecodeError:
self.send_error(400, "bad json")
return
if self.path == "/trace":
self.do_TRACE_post(body)
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"ok":true}')
return
url = body.get("url", "")
if not url:
self.send_error(400, "no url")
return
key = asset_key(url)
action = "ignored"
with _lock:
proc = _procs.get(key)
if proc is not None and proc.poll() is None:
action = "already recording" # healthy: reload is a no-op
else:
if proc is not None:
log(f"RESTART {key}: downloader had exited (rc={proc.returncode})")
_procs[key] = start_recording(key, url, body.get("userAgent"),
body.get("page"))
action = "started"
# Always 200 the browser; the player re-requests manifests constantly.
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps({"ok": True, "action": action}).encode())
def do_TRACE_post(self, body):
"""Discovery mode: append the request/response the page made.
Used to find which API call mints the stream token, so the chain can
run without a GUI browser. Written as JSONL for easy grepping.
"""
os.makedirs(LOGS, exist_ok=True)
with open(os.path.join(LOGS, "trace.jsonl"), "a", encoding="utf-8") as fh:
fh.write(json.dumps(body, ensure_ascii=False) + chr(10))
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def log_message(self, *args):
pass
if __name__ == "__main__":
log(f"catcher listening on http://{HOST}:{PORT}/stream (cookies: {BROWSER})")
HTTPServer((HOST, PORT), Handler).serve_forever()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Credential storage for unattended recording.
Secrets live in the Windows Credential Manager (DPAPI, keyed to your Windows
account), not in a file. Nothing here can be committed by accident because
nothing here is written to the repo.
python creds.py --set-login store email + password (prompts)
python creds.py --show show what is stored (never the password)
python creds.py --clear remove everything
What this does and does not protect against:
DOES -- plaintext at rest, and the classic "gitignored file gets committed
after a git add -A" accident.
DOES -- readable by another Windows user on this machine.
NOT -- a process already running as you. Credential Manager unlocks with
your login session, so anything running as you can read it. That is
inherent to unattended operation: a job that logs in without you
present must be able to reach the secret without you present.
Caveat for Task Scheduler: "Run whether user is logged on or not" runs in a
session where DPAPI may not be able to decrypt these entries. Prefer "Run only
when user is logged on" for any task that needs credentials.
PLATFORM NOTE -- this module is only safe where a real vault backend exists:
Windows WinVaultKeyring (Credential Manager, DPAPI). Good.
Linux+GUI SecretService (GNOME Keyring / KWallet). Good.
Headless No D-Bus session, no unlocked keyring. `keyring` NOT SAFE.
Linux falls back to a fail or plaintext backend.
On a headless Pi or server, do NOT use this module. Use systemd-creds, which
encrypts to the host/TPM key and is decrypted only for the service:
systemd-creds encrypt --name=brfc-pass - /etc/credstore.encrypted/brfc-pass
# then in the unit: LoadCredential=brfc-pass
# and read it from: $CREDENTIALS_DIRECTORY/brfc-pass
Run `python creds.py --check` on each host before trusting it with a secret.
"""
import argparse
import getpass
import sys
try:
import keyring
except ImportError:
sys.exit("keyring not installed. Run: pip install keyring")
SERVICE = "brfc-stream-pirate"
# Keys held in the vault.
K_USER = "username"
K_PASS = "password"
K_COOKIE = "session-cookie" # cached login session, refreshed periodically
def get(key):
return keyring.get_password(SERVICE, key)
def put(key, value):
keyring.set_password(SERVICE, key, value)
def drop(key):
try:
keyring.delete_password(SERVICE, key)
except keyring.errors.PasswordDeleteError:
pass # already absent
def load_login():
"""Return (username, password), or None if not configured yet."""
user, pw = get(K_USER), get(K_PASS)
return (user, pw) if user and pw else None
def load_cookie():
"""Cached session cookie, or None. Callers must handle it being stale."""
return get(K_COOKIE)
def save_cookie(value):
put(K_COOKIE, value)
# Backends that do not actually protect anything. `keyring` selects these
# silently on a headless box, so a secret stored there is effectively plaintext.
UNSAFE_BACKENDS = {"PlaintextKeyring", "Keyring", "fail.Keyring", "EnvironCredential"}
def backend():
return keyring.get_keyring().__class__.__name__
def backend_is_safe():
name = backend()
return name not in UNSAFE_BACKENDS and "fail" not in name.lower()
def _mask(s):
if not s:
return "(not set)"
return s[:2] + "*" * max(0, len(s) - 4) + s[-2:] if len(s) > 4 else "*" * len(s)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--set-login", action="store_true", help="store email + password")
g.add_argument("--show", action="store_true", help="show stored username")
g.add_argument("--clear", action="store_true", help="remove all stored secrets")
g.add_argument("--check", action="store_true",
help="report whether this host has a real vault backend")
args = ap.parse_args()
if args.check:
name = backend()
safe = backend_is_safe()
print(f"backend : {name}")
print(f"verdict : {'SAFE' if safe else 'NOT SAFE -- do not store secrets here'}")
if not safe:
print(" use systemd-creds on this host (see module docstring)")
sys.exit(0 if safe else 1)
if not backend_is_safe():
sys.exit(f"refusing to use backend {backend()!r}: it does not encrypt. "
"Run 'python creds.py --check' for guidance.")
if args.set_login:
user = input("email: ").strip()
if not user:
sys.exit("aborted: empty email")
pw = getpass.getpass("password: ")
if not pw:
sys.exit("aborted: empty password")
put(K_USER, user)
put(K_PASS, pw)
print(f"stored in Windows Credential Manager under '{SERVICE}'")
elif args.show:
print(f"service : {SERVICE}")
print(f"username: {get(K_USER) or '(not set)'}")
print(f"password: {'(set)' if get(K_PASS) else '(not set)'}")
print(f"cookie : {_mask(get(K_COOKIE))}")
elif args.clear:
for k in (K_USER, K_PASS, K_COOKIE):
drop(k)
print("cleared")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Byte-exact manifest fetcher + concurrency test.
curl normalises percent-escapes (%2f -> %2F), which breaks an Akamai path
token because the HMAC covers the literal path. This sends the request target
verbatim via http.client, and can fire N of them at once to see whether the
edge objects to concurrent readers of one token.
python probe.py --url "<manifest url>" # single fetch
python probe.py --url "<...>" --parallel 3 # concurrency test
python probe.py --url "<...>" --cookie "name=val" # with a CDN cookie
"""
import argparse
import http.client
import sys
import threading
from urllib.parse import urlsplit
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
def fetch(url, cookie=None, referer="https://www.bristolrovers.co.uk/", tag=""):
parts = urlsplit(url)
# Rebuild the target from the raw split so nothing is re-encoded.
target = parts.path + (("?" + parts.query) if parts.query else "")
conn = http.client.HTTPSConnection(parts.netloc, timeout=15)
try:
conn.putrequest("GET", target, skip_accept_encoding=True)
conn.putheader("User-Agent", UA)
conn.putheader("Accept", "*/*")
conn.putheader("Referer", referer)
conn.putheader("Origin", referer.rstrip("/"))
if cookie:
conn.putheader("Cookie", cookie)
conn.endheaders()
resp = conn.getresponse()
body = resp.read()
print(f"{tag}HTTP {resp.status} {resp.reason} ({len(body)} bytes)")
if resp.status == 200:
text = body.decode("utf-8", "replace")
variants = [l for l in text.splitlines()
if l.startswith("#EXT-X-STREAM-INF") or l.endswith(".m3u8")]
segs = [l for l in text.splitlines() if l and not l.startswith("#")]
if variants:
print(f"{tag} master playlist, {len(variants)//2 or len(variants)} rendition(s):")
for v in variants[:12]:
print(f"{tag} {v}")
else:
print(f"{tag} media playlist, {len(segs)} segment(s)")
for s in segs[:2]:
print(f"{tag} {s[:110]}")
else:
print(f"{tag} {body.decode('utf-8', 'replace')[:200].strip()}")
return resp.status
except Exception as exc:
print(f"{tag}ERROR {type(exc).__name__}: {exc}")
return -1
finally:
conn.close()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--cookie", default=None)
ap.add_argument("--parallel", type=int, default=1,
help="fire N simultaneous fetches to test concurrency")
args = ap.parse_args()
if args.parallel <= 1:
sys.exit(0 if fetch(args.url, args.cookie) == 200 else 1)
print(f"firing {args.parallel} simultaneous requests "
f"(keep the browser playing while this runs)\n")
results = {}
start = threading.Barrier(args.parallel)
def worker(i):
start.wait() # release them together
results[i] = fetch(args.url, args.cookie, tag=f"[{i}] ")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(args.parallel)]
for t in threads:
t.start()
for t in threads:
t.join()
ok = sum(1 for v in results.values() if v == 200)
print(f"\n{ok}/{args.parallel} succeeded concurrently"
f"{' -- plus the browser itself' if ok else ''}")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
// ==UserScript==
// @name BRFC stream sniffer
// @namespace local.brfc.archive
// @version 0.4
// @description Capture the manifest URL the player requests, and trace the API call that mints its token.
// @match *://*.bristolrovers.co.uk/*
// @match *://*.streamamg.com/*
// @match *://*.streamamg.co.uk/*
// @match *://*.kaltura.com/*
// @match *://*.kaltura.co.uk/*
// @match *://*.akamaized.net/*
// @match *://*.akamaihd.net/*
// @noframes false
// @run-at document-start
// @grant GM_xmlhttpRequest
// @connect 127.0.0.1
// ==/UserScript==
(function () {
'use strict';
const CATCHER = 'http://127.0.0.1:8787/stream';
const TRACER = 'http://127.0.0.1:8787/trace';
// Discovery mode: record the request/response chain that produces a stream
// URL, so the token mint can be replayed without a GUI browser. Set false
// once the endpoint is known -- it writes a lot.
const TRACE = true;
const IDLE_OPACITY = 0.063; // 16/255
const MAX_BODY = 20000; // never log a whole media segment
console.log('[brfc-sniffer] v0.4 active in frame:', location.href);
const seen = new Map();
const isManifest = u => typeof u === 'string' && /\.m3u8(\?|$)/i.test(u);
// Media and assets are noise. Everything else is a mint-call candidate.
const isNoise = u => /\.(ts|mp4|m4s|aac|jpe?g|png|gif|webp|svg|woff2?|css)(\?|$)/i.test(u);
function post(endpoint, payload) {
try {
GM_xmlhttpRequest({
method: 'POST',
url: endpoint,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
onerror: () => {}
});
} catch (e) { /* catcher not running; never break the page */ }
}
function record(url, via) {
let abs = url;
if (!/^https?:/i.test(url)) {
try { abs = new URL(url, location.href).href; } catch (e) { /* keep raw */ }
}
if (seen.has(abs)) return;
seen.set(abs, { url: abs, via });
console.log('[brfc-sniffer]', via, abs);
render();
post(CATCHER, {
url: abs, via, page: location.href,
userAgent: navigator.userAgent, ts: new Date().toISOString()
});
}
// The mint call is identified by what it RETURNS, not by its name. Any
// response mentioning m3u8 is a candidate, whatever the endpoint is called.
function trace(entry, bodyText) {
if (!TRACE) return;
const mints = !!bodyText && /m3u8/i.test(bodyText);
if (!mints && !/api|token|entitle|session|auth|manifest|playback|licen/i.test(entry.url)) return;
entry.mintsManifest = mints;
entry.body = bodyText ? bodyText.slice(0, MAX_BODY) : null;
entry.page = location.href;
entry.ts = new Date().toISOString();
if (mints) console.log('[brfc-sniffer] *** MINT CALL ***', entry.method, entry.url);
post(TRACER, entry);
}
// --- fetch ----------------------------------------------------------------
const _fetch = window.fetch;
if (_fetch) {
window.fetch = function (input, init) {
let url = null, method = 'GET';
try {
url = (typeof input === 'string') ? input : (input && input.url) || null;
method = (init && init.method) || (input && input.method) || 'GET';
if (isManifest(url)) record(url, 'fetch');
} catch (e) { /* ignore */ }
const p = _fetch.apply(this, arguments);
if (TRACE && url && !isNoise(url)) {
p.then(resp => {
try {
const reqBody = (init && typeof init.body === 'string')
? init.body.slice(0, MAX_BODY) : null;
resp.clone().text().then(
t => trace({ via: 'fetch', method, url, status: resp.status, reqBody }, t),
() => {}
);
} catch (e) { /* ignore */ }
}, () => {});
}
return p;
};
}
// --- XHR ------------------------------------------------------------------
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try {
this.__brfc = { method: method, url: url };
if (isManifest(url)) record(url, 'xhr');
} catch (e) { /* ignore */ }
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
try {
const info = this.__brfc;
if (TRACE && info && info.url && !isNoise(info.url)) {
const reqBody = (typeof body === 'string') ? body.slice(0, MAX_BODY) : null;
this.addEventListener('load', () => {
let text = null;
try {
if (this.responseType === '' || this.responseType === 'text') {
text = this.responseText;
}
} catch (e) { /* binary response */ }
trace({ via: 'xhr', method: info.method, url: info.url,
status: this.status, reqBody: reqBody }, text);
});
}
} catch (e) { /* ignore */ }
return _send.apply(this, arguments);
};
// --- <video src> ----------------------------------------------------------
const _setAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
try {
if (/^src$/i.test(name) && isManifest(value)) record(value, 'attr');
} catch (e) { /* ignore */ }
return _setAttr.apply(this, arguments);
};
// --- panel ----------------------------------------------------------------
let panel;
function render() {
if (window.top !== window.self) return;
if (!panel) {
panel = document.createElement('div');
panel.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:2147483647',
'max-width:min(560px,90vw)', 'max-height:40vh', 'overflow:auto',
'background:#101418', 'color:#e6edf3',
'font:12px/1.45 ui-monospace,Menlo,Consolas,monospace',
'border:1px solid #2b333c', 'border-radius:8px', 'padding:10px 12px',
'box-shadow:0 8px 24px rgba(0,0,0,.45)',
'opacity:' + IDLE_OPACITY,
'transition:opacity .18s ease'
].join(';');
panel.addEventListener('mouseenter', () => { panel.style.opacity = '1'; });
panel.addEventListener('mouseleave', () => { panel.style.opacity = String(IDLE_OPACITY); });
(document.body || document.documentElement).appendChild(panel);
}
panel.innerHTML = '<b style="color:#5ac8fa">BRFC sniffer v0.4</b> '
+ seen.size + ' manifest(s)'
+ (TRACE ? ' <span style="color:#e3b341">[tracing]</span>' : '') + '<br>';
for (const rec of seen.values()) {
const row = document.createElement('div');
row.style.cssText = 'margin-top:8px;word-break:break-all;'
+ 'border-top:1px solid #2b333c;padding-top:6px';
const btn = document.createElement('button');
btn.textContent = 'copy';
btn.style.cssText = 'margin-right:8px;cursor:pointer;background:#1f6feb;'
+ 'color:#fff;border:0;border-radius:4px;padding:2px 8px';
btn.onclick = () => navigator.clipboard.writeText(rec.url).then(() => {
btn.textContent = 'copied';
setTimeout(() => { btn.textContent = 'copy'; }, 1200);
});
row.appendChild(btn);
row.appendChild(document.createTextNode('[' + rec.via + '] ' + rec.url));
panel.appendChild(row);
}
}
})();