Files
brfc-stream-pirate/catcher.py
T
Mikkeli Matlock f476e85209 Add unattended recorder that finds the live stream itself
record_live.py runs the whole chain alone: headless browser only to learn the
entry id (it appears at runtime; static HTML has none when nothing is live),
then plain Python mints a fresh master playlist, then ffmpeg records.

No password needed. Firefox session cookies run to late November, well past
any kickoff, so the job authenticates from the stored session.

Three failures found by testing against a live feed rather than at kickoff:

- yt-dlp's native HLS downloader fetches variant playlists with Python and
  gets 403/404 on path-token URLs. Forced --downloader ffmpeg; this is
  required, not a preference.
- ffmpeg 4.3.1 has no -reconnect_on_network_error. Removed here and in
  catcher.py, which had the same unsupported flag.
- A master playlist outlives its stream: after a match ends the master is
  still served while its variants 404. So a found master does not mean a live
  stream, and a downloader that dies immediately means "not up yet" rather
  than "failed". It now retries until the wait window expires.

Untested: the actual segment download for an audio-only stream, because
nothing is live right now. Video was verified working on 29 Aug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
2026-09-01 21:00:08 +09:00

153 lines
5.6 KiB
Python

#!/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 4.3.1 has no -reconnect_on_network_error.
"ffmpeg_i:-reconnect 1 -reconnect_streamed 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()