Compare commits

...

3 Commits

Author SHA1 Message Date
Mikkeli Matlock 84c29cc0b7 Correct two disproved claims and record what is actually verified
The TLS-handshake explanation for the 403 was wrong. The split is by token
form, not by client: Python fetches the master (query token) fine and fails
only on the media playlist (path token). That is why record_live.py mints a
master and why --downloader ffmpeg is required.

"The stream never ends" was one video observation stated as a rule, and an
audio stream has since contradicted it by terminating cleanly well inside the
cap. Both are now recorded as single observations rather than conclusions.

Also records the state of play: unattended recording works, login has never
been tested, and the decision not to automate it -- the cookie outlives a
quarter, so automating the riskiest step to save four manual logins a year is
a bad trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
2026-09-05 18:49:00 +09:00
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
Mikkeli Matlock ce92e55255 Map the full token chain and prove it needs no browser
Playwright discovery against the live feed found every link, then the whole
chain was replayed in plain urllib with no browser at all:

  SSESS cookie -> GET /oauth/browser-token          -> JWT (90 min)
  JWT + x-api-key -> GET /v1/entry/{id}             -> hdnts master URL (~25s)
  master -> hdntl token (24h) -> ffmpeg

Both auth hops live off the Akamai CDN, so plain Python reaches them; ffmpeg
handles the CDN, which is the only host Python gets 403s from. Nothing needs
a fingerprint forged and Camoufox is now a fallback rather than the plan.

Two assumptions this disproved:
- The play click is not part of auth. Manifests arrive before any click, so
  a headless run needs no interaction to mint a token.
- The 90 minute JWT does not constrain a 2 hour recording. It is spent before
  ffmpeg starts; the 24h hdntl token carries the match.

discover.py drives the page with Playwright and identifies the mint and token
endpoints by what they RETURN rather than by guessing endpoint names, so it
survives the endpoints being renamed. It rides existing Firefox cookies and
needs no password. Verified working with --headless.

Sniffer v0.5 adds muted autoplay: the browser only has to trigger the manifest
fetch, ffmpeg records real audio separately, and muted autoplay is permitted
where audible is not. It stands down once playback starts, because the control
is a toggle and clicking twice would pause the recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
2026-09-01 11:45:04 +09:00
5 changed files with 617 additions and 12 deletions
+70 -11
View File
@@ -96,13 +96,25 @@ The master response rewrites each variant URL with a 24h `hdntl` token. So the
- Player is **Bitmovin**, embedded on the club site. `0_xm5vrrw6` is a Kaltura
entry id and identifies the match -- the catcher dedupes on it.
### Gotcha: use ffmpeg/yt-dlp, not a hand-rolled HTTP client
### Gotcha: which URL form a Python client can fetch
Python's `http.client` gets a **403 on every request**, with any header set,
including ffmpeg's exact headers, on an unexpired token from the same IP. The
difference is below HTTP (TLS handshake). ffmpeg fetches the same URL fine, so
just use yt-dlp. `probe.py` is diagnostic scaffolding and inherits this 403 --
do not use it to conclude a token is dead.
Earlier notes here blamed a TLS handshake difference. That was wrong. The 403
splits by **token form**, not by client:
| URL form | Python | ffmpeg |
|---|---|---|
| `index.m3u8?hdnts=...` (master, query token) | 200 | 200 |
| `hdntl=.../index_N.m3u8` (media, path token) | 403 | 200 |
Consequences:
- Mint a **master** URL and hand that to the downloader. `record_live.py` does
this: it calls the entry API for a fresh master immediately before recording.
- Force `--downloader ffmpeg`. yt-dlp's native HLS downloader fetches variant
playlists with Python and dies on the path-token URLs. This is required, not
a preference.
- `probe.py` is diagnostic scaffolding and inherits the path-token 403 -- do
not use it to conclude a token is dead.
### Firefox, not Chrome
@@ -118,9 +130,56 @@ is no `#EXT-X-ENDLIST`, so ffmpeg never finishes, and there is no stall to
detect either -- from the downloader's point of view it is a healthy live
stream forever.
**A hard duration cap is therefore the only stop condition.** `catcher.py`
passes `ffmpeg_o:-t $DURATION_MIN*60`. Do not remove it; without it an
unattended run fills the disk with a logo.
**Keep the duration cap.** `catcher.py` and `record_live.py` pass
`ffmpeg_o:-t $DURATION_MIN*60`. Without it, a feed that behaves like the video
one above fills the disk with a logo.
Observed: 116 min recorded for a 23:00 kickoff, still going at 01:04 and only
stopped manually. Budget ~3.5-4 GB per copy per match at 40 MB/min.
Note the sample size. This is **one video observation**, and it is contradicted
by **one audio observation**: on 2026-09-02 an audio stream ended cleanly by
itself (`rc=0` at 165 min, against a 180 min cap that never fired). So "the
stream never ends" is a property of that video feed, not an established rule.
Treat the cap as a safety net rather than as the only stop condition.
Observed: video, 116 min recorded for a 23:00 kickoff, still running at 01:04
and stopped manually. Audio, 165 min, self-terminated. Budget ~3.5-4 GB per copy per match at 40 MB/min.
---
## Status
Unattended recording works. A scheduled task started `record_live.py` at 03:15
for an 03:45 kickoff, found the stream in 16 seconds, and recorded 165 min of
audio to a playable MPEG-TS with nobody awake.
Sample sizes are one. One video capture, one audio capture. Where the two
disagree (stream termination, whether an entry id persists between matches),
neither is established -- do not build on either without more matches.
### Verified
- Token chain end to end in plain Python: session cookie -> JWT -> entry API
-> master playlist. No browser, no fingerprint forging.
- Recording via ffmpeg, video (1080p) and audio (~122 kbps AAC).
- Scheduled unattended run, including that Task Scheduler launches it correctly.
- Concurrency: several readers of one token, alongside a playing browser.
### Not verified
- **Login.** Everything above rides an `SSESS` cookie lifted from a desktop
Firefox profile. Obtaining a session *from credentials* has never been run.
`/user/login` is a standard Drupal form (`name`, `pass`, `form_build_id`,
`form_id`, `captcha_token`) with no reCAPTCHA script and no 2FA mention, but
`captcha_token` is an unknown and testing it risks a lockout that would take
out the working session too.
### Decision: do not automate login
The cookie lasts ~87 days and matches are weekly, so automating login saves
about four manual actions a year -- while being the riskiest step and the only
one needing a stored password. Refresh the cookie by logging in manually on the
desktop each quarter and provisioning it to the recording host instead. That
makes cookie *provisioning* the open problem, not the captcha.
Use Firefox as the session holder. Two independent reasons: its cookie DB stays
readable while running (Chrome/Edge lock theirs), and it holds the login
durably where Chrome has been observed dropping it within a day.
+2 -1
View File
@@ -68,8 +68,9 @@ def start_recording(key, url, user_agent, page):
# 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_on_network_error 1 -reconnect_delay_max 30",
"-reconnect_delay_max 30",
"--downloader-args", f"ffmpeg_o:-t {DURATION_MIN * 60}",
"-o", out,
url,
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Drive the player with Playwright and capture the traffic that mints a token.
Purpose: find which request produces a stream URL, so the mint can eventually
run without a GUI. Playwright sees request AND response bodies natively, so
this replaces the userscript's hooks with something far less fragile.
No password is needed. Your existing Firefox session cookies are injected into
a fresh browser context, so this rides the login you already have.
python discover.py --url "<match page url>"
python discover.py --url "..." --headless
Ports to Camoufox on halogen by swapping the launcher: Camoufox speaks the
Playwright protocol, so the flow below is unchanged.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
ROOT = os.path.dirname(os.path.abspath(__file__))
LOGS = os.path.join(ROOT, "logs")
TRACE = os.path.join(LOGS, "pw-trace.jsonl")
# Only these domains' cookies are handed to the browser. Everything else in
# the Firefox jar is irrelevant here and should not be copied around.
COOKIE_DOMAINS = ("bristolrovers", "streamamg", "kaltura", "akamaized")
# Bodies we never want to read: media segments would be dumped into the log.
SKIP_BODY = (".ts", ".mp4", ".m4s", ".aac", ".jpg", ".jpeg", ".png", ".gif",
".webp", ".svg", ".woff", ".woff2", ".css", ".ico")
MAX_BODY = 20000
def firefox_cookies():
"""Lift the live session from Firefox. Requires no password."""
from yt_dlp.cookies import extract_cookies_from_browser
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
jar = extract_cookies_from_browser("firefox")
out = []
for c in jar:
dom = (c.domain or "")
if not any(k in dom.lower() for k in COOKIE_DOMAINS):
continue
out.append({
"name": c.name,
"value": c.value or "",
"domain": dom,
"path": c.path or "/",
"expires": float(c.expires) if c.expires else -1,
"httpOnly": False,
"secure": bool(c.secure),
"sameSite": "Lax",
})
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True, help="match page URL")
ap.add_argument("--headless", action="store_true",
help="run without a window (what halogen will do)")
ap.add_argument("--seconds", type=int, default=90,
help="how long to watch after load")
args = ap.parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed: pip install playwright "
"&& python -m playwright install firefox")
os.makedirs(LOGS, exist_ok=True)
cookies = firefox_cookies()
print(f"injecting {len(cookies)} session cookies")
manifests, mints, jwts, seen = [], [], [], set()
fh = open(TRACE, "a", encoding="utf-8")
def write(rec):
rec["ts"] = datetime.now().isoformat()
fh.write(json.dumps(rec, ensure_ascii=False) + chr(10))
fh.flush()
with sync_playwright() as p:
browser = p.firefox.launch(headless=args.headless)
ctx = browser.new_context(
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) "
"Gecko/20100101 Firefox/131.0"),
viewport={"width": 1440, "height": 900},
)
if cookies:
try:
ctx.add_cookies(cookies)
except Exception as exc:
print(f"cookie injection failed: {exc}")
page = ctx.new_page()
def on_response(resp):
url = resp.url
if url in seen:
return
low = url.split("?")[0].lower()
if ".m3u8" in low:
seen.add(url)
manifests.append(url)
print(f" manifest: {url[:120]}")
write({"kind": "manifest", "url": url, "status": resp.status})
return
if any(low.endswith(e) for e in SKIP_BODY):
return
try:
body = resp.text()
except Exception:
return
if not body:
return
# The JWT that authorises the mint is issued somewhere on the club
# site off the session cookie. Detect it the same way: by output.
# A JWT is three base64 segments starting with a b64 '{"alg"'.
if re.search(r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.", body):
seen.add(url)
req = resp.request
rec = {
"kind": "JWT-ISSUER",
"method": req.method,
"url": url,
"status": resp.status,
"reqHeaders": dict(req.headers),
"reqBody": (req.post_data or None),
"body": body[:MAX_BODY],
}
jwts.append(rec)
write(rec)
print(f" *** JWT ISSUER *** {req.method} {url[:100]}")
return
# The mint call is whatever RETURNS a manifest URL, whatever it is
# named. Detect by output, not by guessing endpoint names.
if "m3u8" in body.lower():
seen.add(url)
req = resp.request
rec = {
"kind": "MINT",
"method": req.method,
"url": url,
"status": resp.status,
"reqHeaders": dict(req.headers),
"reqBody": (req.post_data or None),
"body": body[:MAX_BODY],
}
mints.append(rec)
write(rec)
print(f" *** MINT *** {req.method} {url[:110]}")
page.on("response", on_response)
print(f"loading {args.url}")
page.goto(args.url, wait_until="domcontentloaded", timeout=60000)
# The player needs a click. Muted playback is enough to trigger the
# manifest fetch, and muted autoplay is not blocked.
page.wait_for_timeout(5000)
for sel in (".bmpui-ui-hugeplaybacktogglebutton",
".bmpui-ui-playbacktogglebutton",
"button[aria-label*='play' i]"):
try:
el = page.query_selector(sel)
if el and el.is_visible():
el.click()
print(f"clicked {sel}")
break
except Exception:
pass
try:
page.evaluate("() => { const v = document.querySelector('video');"
" if (v) { v.muted = true; v.play(); } }")
except Exception:
pass
print(f"watching for {args.seconds}s ...")
page.wait_for_timeout(args.seconds * 1000)
browser.close()
fh.close()
print(f"\nmanifests seen : {len(manifests)}")
print(f"mint calls : {len(mints)}")
for m in mints:
print(f"\n {m['method']} {m['url']}")
print(f" status {m['status']} reqBody={str(m['reqBody'])[:200]}")
print(f"\nfull trace -> {TRACE}")
if __name__ == "__main__":
main()
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Unattended recorder: find the live stream, then record it.
Why this needs a headless browser at all: auth and the token mint are plain
HTTP (see discover.py), but the *entry id* for a given match only appears in
the page at runtime -- the static HTML carries no id when nothing is live. So
we render the page headlessly just long enough to learn the manifest URL, then
hand it to ffmpeg and close the browser.
Nothing here needs your password. It rides the Firefox session cookies, which
outlive kickoff by months.
# start now, keep looking for up to 45 min, then record 150 min
python record_live.py --max-wait 45 --duration 150
# or wait until a wall-clock time first (03:15 before an 03:45 kickoff)
python record_live.py --at "2026-09-02 03:15" --max-wait 60
Recording stops on the duration cap. That is deliberate and load-bearing: the
feed does not emit #EXT-X-ENDLIST -- after full time it switches to a static
slate and streams forever.
"""
import argparse
import contextlib
import io
import os
import re
import subprocess
import sys
import time
from datetime import datetime
ROOT = os.path.dirname(os.path.abspath(__file__))
RECORDINGS = os.path.join(ROOT, "recordings")
LOGS = os.path.join(ROOT, "logs")
LIVE_URL = "https://www.bristolrovers.co.uk/video/live"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) "
"Gecko/20100101 Firefox/131.0")
COOKIE_DOMAINS = ("bristolrovers", "streamamg", "kaltura", "akamaized")
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, "record_live.log"), "a", encoding="utf-8") as fh:
fh.write(line + chr(10))
def firefox_cookies():
from yt_dlp.cookies import extract_cookies_from_browser
with contextlib.redirect_stderr(io.StringIO()):
jar = extract_cookies_from_browser("firefox")
out = []
for c in jar:
dom = c.domain or ""
if not any(k in dom.lower() for k in COOKIE_DOMAINS):
continue
out.append({"name": c.name, "value": c.value or "", "domain": dom,
"path": c.path or "/",
"expires": float(c.expires) if c.expires else -1,
"httpOnly": False, "secure": bool(c.secure),
"sameSite": "Lax"})
return out
TOKEN_URL = ("https://www.bristolrovers.co.uk/oauth/browser-token"
"?client_id=streamamg&token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token")
# Public client key, shipped to every browser -- not a secret.
API_KEY = "50ivbQ3IXpF2mwT4tPAJ2V6cVm4CGRT2YKmDK5v6"
def mint_master(entry_id):
"""Mint a fresh master playlist URL in plain Python. No browser.
Handing yt-dlp the *media* playlist (hdntl, path token) fails: Python
clients get 403 on that URL form, and yt-dlp fetches the playlist with
urllib before ffmpeg ever runs. The *master* (hdnts, query token) is fetched
fine by Python, so we mint one here and pass that instead. It lives only
~25 seconds, which is why it is minted immediately before recording.
"""
import http.cookiejar, json, re, urllib.request
from yt_dlp.cookies import extract_cookies_from_browser
with contextlib.redirect_stderr(io.StringIO()):
jar = extract_cookies_from_browser("firefox")
cj = http.cookiejar.CookieJar()
for c in jar:
if "bristolrovers" in (c.domain or "").lower():
cj.set_cookie(c)
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
hdrs = {"User-Agent": UA, "Accept": "application/json, text/plain, */*"}
body = op.open(urllib.request.Request(TOKEN_URL, headers=hdrs),
timeout=20).read().decode("utf-8", "replace")
m = re.search(r"(eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+)", body)
if not m:
log(" mint: no JWT in browser-token response (session expired?)")
return None
jwt = m.group(1)
req = urllib.request.Request(
f"https://api.playback.streamamg.com/v1/entry/{entry_id}",
headers={**hdrs, "Authorization": "Bearer " + jwt, "x-api-key": API_KEY,
"Origin": "https://www.bristolrovers.co.uk",
"Referer": "https://www.bristolrovers.co.uk/"})
data = json.loads(urllib.request.urlopen(req, timeout=20)
.read().decode("utf-8", "replace"))
return (data.get("media") or {}).get("hls") or None
def find_manifest(timeout_s=90):
"""Load the live page headlessly and return the entry id, or None.
The browser is needed only for this: the entry id appears at runtime, and
the static HTML carries none when nothing is live. Everything after this
is plain HTTP.
"""
from playwright.sync_api import sync_playwright
found = {"master": None, "media": None}
with sync_playwright() as p:
browser = p.firefox.launch(headless=True)
ctx = browser.new_context(user_agent=UA,
viewport={"width": 1440, "height": 900})
ctx.add_cookies(firefox_cookies())
page = ctx.new_page()
def on_response(resp):
u = resp.url
if ".m3u8" not in u.split("?")[0].lower():
return
if "hdntl=" in u and not found["media"]:
found["media"] = u
elif "hdnts=" in u and not found["master"]:
found["master"] = u
page.on("response", on_response)
try:
page.goto(LIVE_URL, wait_until="domcontentloaded", timeout=60000)
except Exception as exc:
log(f" page load failed: {exc}")
browser.close()
return None
# The manifest is fetched on load, before any click. We still click,
# because a stream that has only just gone live may need the nudge.
deadline = time.time() + timeout_s
clicked = False
while time.time() < deadline and not found["media"]:
if not clicked:
for sel in (".bmpui-ui-hugeplaybacktogglebutton",
".bmpui-ui-playbacktogglebutton",
"button[aria-label*='play' i]"):
try:
el = page.query_selector(sel)
if el and el.is_visible():
el.click()
clicked = True
break
except Exception:
pass
with contextlib.suppress(Exception):
page.evaluate("() => { const v=document.querySelector('video');"
" if (v) { v.muted = true; v.play(); } }")
page.wait_for_timeout(2000)
browser.close()
seen_url = found["media"] or found["master"]
if not seen_url:
return None
m = re.search(r"/(\d+_[a-z0-9]+)/", seen_url, re.I)
return m.group(1) if m else None
def record(url, duration_min):
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = os.path.join(RECORDINGS, f"brfc-{stamp}.ts")
os.makedirs(RECORDINGS, exist_ok=True)
logfile = os.path.join(LOGS, f"yt-dlp-{stamp}.log")
cmd = [
sys.executable, "-m", "yt_dlp",
# Force ffmpeg. yt-dlp's native HLS downloader fetches variant
# playlists with Python and gets 403/404 on the path-token URLs;
# ffmpeg fetches them fine. This is not a preference, it is required.
"--downloader", "ffmpeg",
"--hls-use-mpegts", # a killed process still leaves a playable file
"--no-part",
"--cookies-from-browser", "firefox",
"--user-agent", UA,
"--referer", "https://www.bristolrovers.co.uk/",
"--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,
]
log(f"recording -> {os.path.basename(out)} (cap {duration_min} min)")
log(f" yt-dlp log -> {os.path.basename(logfile)}")
with open(logfile, "w", encoding="utf-8") as fh:
return subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--at", help='wall-clock start, e.g. "2026-09-02 03:15"')
ap.add_argument("--max-wait", type=int, default=45,
help="minutes to keep looking for a live stream")
ap.add_argument("--duration", type=int, default=150,
help="recording cap in minutes")
ap.add_argument("--poll", type=int, default=60, help="seconds between attempts")
args = ap.parse_args()
if args.at:
target = datetime.strptime(args.at, "%Y-%m-%d %H:%M")
wait = (target - datetime.now()).total_seconds()
if wait > 0:
log(f"sleeping {wait/3600:.2f}h until {target:%Y-%m-%d %H:%M}")
time.sleep(wait)
deadline = time.time() + args.max_wait * 60
attempt = 0
while time.time() < deadline:
attempt += 1
log(f"attempt {attempt}: looking for a live stream")
try:
entry_id = find_manifest()
except Exception as exc:
log(f" error: {type(exc).__name__}: {exc}")
entry_id = None
if entry_id:
log(f" live entry: {entry_id}")
try:
url = mint_master(entry_id)
except Exception as exc:
log(f" mint failed: {type(exc).__name__}: {exc}")
url = None
if not url:
log(" could not mint a manifest; retrying")
time.sleep(args.poll)
continue
log(" minted fresh master playlist")
proc = record(url, args.duration)
log(f" downloader pid {proc.pid}; waiting for it to finish")
t0 = time.time()
proc.wait()
ran = time.time() - t0
log(f" downloader exited rc={proc.returncode} after {ran:.0f}s")
# A master playlist outlives its stream: after a match ends the
# master is still served while its variants 404. So "found a
# master" does not mean "stream is live" -- only a download that
# actually ran does. Anything that dies immediately means the
# stream is not up yet, so keep waiting rather than giving up.
if proc.returncode == 0 or ran > 120:
return 0
log(" died immediately -- stream not live yet, will retry")
time.sleep(args.poll)
continue
log(f" nothing live yet; retrying in {args.poll}s")
time.sleep(args.poll)
log("gave up: no live stream appeared within the wait window")
return 1
if __name__ == "__main__":
sys.exit(main())
+70
View File
@@ -27,6 +27,13 @@
// once the endpoint is known -- it writes a lot.
const TRACE = true;
// Start playback without a human. The player needs a click, but the browser
// only has to trigger the manifest fetch -- ffmpeg records the real audio
// independently, so playing MUTED costs nothing and muted autoplay is
// permitted by Firefox policy where audible autoplay is not.
const AUTOPLAY = true;
const AUTOPLAY_TIMEOUT_MS = 180000; // pre-match margin is long; keep trying
const IDLE_OPACITY = 0.063; // 16/255
const MAX_BODY = 20000; // never log a whole media segment
@@ -188,4 +195,67 @@
panel.appendChild(row);
}
}
// --- autoplay -------------------------------------------------------------
// Runs in whichever frame owns the <video>, which may be the player iframe.
function findVideo() {
const v = document.querySelector('video');
return (v && v.isConnected) ? v : null;
}
function clickPlayButton() {
const sels = [
'.bmpui-ui-hugeplaybacktogglebutton', // Bitmovin centre button
'.bmpui-ui-playbacktogglebutton', // Bitmovin control bar
'button[aria-label*="play" i]',
'[class*="play-button"]', '[class*="playButton"]'
];
for (const s of sels) {
const el = document.querySelector(s);
if (el && el.offsetParent !== null) { // must be visible to be real
el.click();
console.log('[brfc-sniffer] autoplay: clicked', s);
return true;
}
}
return false;
}
function startAutoplay() {
if (!AUTOPLAY) return;
const deadline = Date.now() + AUTOPLAY_TIMEOUT_MS;
const timer = setInterval(() => {
const v = findVideo();
// Done once something is actually playing. Critically, we must stop
// here: the play control is a TOGGLE, so clicking it again would pause
// playback and silently end the recording.
if (v && !v.paused && !v.ended && v.readyState > 2) {
console.log('[brfc-sniffer] autoplay: playing, standing down');
clearInterval(timer);
return;
}
if (Date.now() > deadline) {
console.log('[brfc-sniffer] autoplay: gave up after',
AUTOPLAY_TIMEOUT_MS / 1000, 's');
clearInterval(timer);
return;
}
if (v) {
v.muted = true; // audible autoplay is blocked; muted is not
const p = v.play();
if (p && p.catch) p.catch(() => { clickPlayButton(); });
} else {
clickPlayButton(); // player chrome may exist before <video>
}
}, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startAutoplay);
} else {
startAutoplay();
}
})();