f476e85209
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
153 lines
5.6 KiB
Python
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()
|