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
+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()