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