Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84c29cc0b7 | |||
| f476e85209 | |||
| ce92e55255 |
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user