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:
@@ -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()
|
||||
Reference in New Issue
Block a user