It had grown into a changelog: how each fact was discovered, which earlier claim it overturned, what the sample size was at the time. Useful while learning the system, noise once you just want to record a match. Rewritten as a reference, 295 lines to ~195, losing the archaeology and keeping the facts. Conclusions that were buried in narrative are now rules. Sample-size markers survive only where they change whether a claim can be leaned on. Documents --feed, including that it is the setting that matters and that getting it wrong fails silently. Two corrections while here. Of five observed endings, three self-terminated and two ran forever, across both feed types with nothing predicting which -- so the cap stays, but "the stream never ends" was too strong. And preflight no longer claims to mint a JWT: /oauth/browser-token hands back the session's existing token, so the expiry it prints is not a freshness signal. The check is unaffected, since it rests on the 403 a dead session returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fr1fLMnVGNAKH5L2BmhMxn
9.1 KiB
brfc-stream-pirate
Personal time-shifting archive for Bristol Rovers video pass streams. Kickoffs land at unfriendly hours in JST; this records them unattended.
Stream is plain HLS, no DRM (StreamAMG behind Akamai). Nothing here breaks a protection measure -- it replays the same authenticated request the player makes, using your own paid session.
Pieces
| File | Job |
|---|---|
record_live.py |
Unattended recorder: finds the live entry, mints a master, records |
preflight.py |
Pre-match check: is the session live, can this host record |
watchdog.py |
Backup starter: if nothing is progressing, start a recorder |
catcher.py |
Listens on 127.0.0.1:8787, hands a sniffed URL to yt-dlp |
tampermonkey/brfc-stream-sniffer.user.js |
Hooks fetch/XHR, POSTs the manifest URL to the catcher |
discover.py |
Drives the player with Playwright to capture token-minting traffic |
probe.py |
Byte-exact manifest fetch + concurrency test |
Recording a match
python preflight.py # exit 0 only if the session is live
Mandatory, hours before kickoff. A dead session is invisible locally and the only fix needs a human at a browser. Then schedule the recorder for ~30 min before kickoff:
python record_live.py --max-wait 75 --duration 180 --feed video
Pick the feed, not just the rendition. The live page can expose two entries
for one match -- an audio feed and a video feed -- and which manifest loads
first is a race that has come out differently on the same night. --feed
chooses between entries and is the setting that matters:
--feed |
Behaviour |
|---|---|
video (default) |
record a video feed; fall back to audio if none is offered |
audio |
the reverse |
any |
whichever was discovered first -- the old behaviour |
both |
record every feed in parallel |
--entry <id> |
skip discovery entirely and pin one |
Feeds are classified by the codecs in their master, never by entry id. A non-preferred feed stays on the list as a fallback, because a feed you hold no pass for resolves a master fine and only fails later on the segments; the recorder falls through to the next candidate when a download dies immediately.
--prefer is the other choice -- which rendition inside one master -- and
best is almost always right. A wrong --feed cannot be rescued by --prefer,
and it fails quietly: a complete, playable, healthy recording of the wrong
thing. --feed both costs ~210 MB extra and removes the question.
Warm the scheduled task. After registering it, launch it once by hand and
stop it again. A freshly registered task has been seen to hang at interpreter
startup -- alive, zero CPU, nothing logged, frozen before any of our code runs.
Relaunching fixed it; root cause unknown. Stopping the test run matters as much:
MultipleInstances IgnoreNew means a test left running swallows the real
trigger silently.
Optionally schedule watchdog.py ~20 min later as a backup. It checks whether
recording is progressing (a growing .ts, or a freshly written log), not
whether a process exists -- a hung recorder passes the process check.
Manual alternative
python catcher.py # leave running
Install the userscript, open the match page, press play. The sniffer catches the manifest, the catcher starts yt-dlp. Config via environment:
| Variable | Default | Effect |
|---|---|---|
BRFC_BROWSER |
firefox |
which browser's cookie jar yt-dlp reads |
BRFC_DURATION_MIN |
150 |
catcher's recording cap, minutes |
Check concurrency against a live token with probe.py --url "<manifest>" --parallel 3.
The token
Manifest URLs carry an Akamai path token:
hdntl=exp=<unix>~acl=/*~id=<n>~data=hdntl~hmac=<sig>
It is a bearer credential with acl=/* over the whole host, valid to exp.
Anyone holding a live URL can pull the stream. Keep it out of pastebins and
issue trackers; stream-url.txt and cookies.txt are gitignored.
Two tokens, not one:
| Token | Where | TTL | Fetches |
|---|---|---|---|
hdnts= |
query string, acl=/* unencoded |
~25 seconds | master playlist |
hdntl= |
URL path, acl=%2f* encoded |
~24 hours | media playlists, segments |
The master response rewrites each variant URL with a 24h hdntl. Chasing a
hdnts URL by hand is pointless -- it expires before you can paste it.
Do not re-encode the path. curl normalises %2f to %2F, invalidating the
HMAC. probe.py sends the request target verbatim.
Rules that are not preferences
- Hand the downloader a master URL, not a media one. Python gets 403 on
path-token media playlists; ffmpeg gets 200. yt-dlp fetches playlists with
Python before ffmpeg runs, so it must be given a
hdntsmaster.record_live.pymints one immediately before recording. - Force
--downloader ffmpeg. yt-dlp's native HLS downloader dies on the path-token URLs for the same reason. - Keep the duration cap. A feed can keep serving after full time -- a static
slate on video, digital silence on audio -- at full bitrate, with no
#EXT-X-ENDLISTand no stall to detect. Of five observed endings three self-terminated and two ran forever, on both feed types, with no pattern that predicts which. Treat a stream that stops by itself as luck. - Use Firefox as the session holder. Chrome/Edge lock their cookie DB while running (yt-dlp issue 7271) and Chrome 127+ on Windows adds app-bound cookie encryption; an unattended job cannot close a browser to read them.
- Do not judge a token by
probe.py. It inherits the path-token 403, so a live token looks dead through it.
Facts
- 1920x1080 H.264 + AAC ~5.2 Mbit/s. Audio-only feeds are ~140 kbps AAC.
- Output is MPEG-TS even when named
.mp4-- that is what--hls-use-mpegtsbuys: a killed process still leaves a playable file. - Budget ~36 MB/min video (~6.5 GB for a 180 min cap), ~1.2 MB/min audio (~210 MB).
- Concurrency is not limited. An Akamai edge token is a stateless HMAC with no viewer counter. Three readers of one token ran at full bitrate alongside a playing browser.
- No CDN cookies exist. All relevant cookies are on
bristolrovers.co.uk, none onakamaized.net. The token in the URL is the whole auth. - Player is Bitmovin. The Kaltura entry id names the feed, not the match:
0_xm5vrrw6(video) and0_z236v0e7(audio) each recurred weeks apart. Nothing may use it to answer "have I recorded this game" -- use the date.catcher.pyis unaffected; it dedupes on liveness, not identity.
The session
The SSESS cookie is the whole login. Everything downstream -- JWT, entry API,
master playlist -- rides it, in plain Python, with no browser or fingerprint
forging.
Cookie expiry is not session lifetime. The server can drop a session while the browser holds a cookie that looks perfectly healthy:
SSESS... 76 days left on the clock
GET /oauth/browser-token -> 403 {"error":"Unauthenticated"}
Set-Cookie: SSESS...=deleted; expires=Thu, 01 Jan 1970
One session died after six days; its replacement survived seven and counting.
The lifetime is unpredictable and only the server knows it, so a local cookie
check proves nothing and preflight.py is not a formality.
Other session behaviour:
- Sessions are concurrent. Logging in via a second browser does not invalidate the first -- Drupal issues a session per browser. So a second device can hold its own session without disturbing the recorder's. One observation, checked immediately after the second login.
/oauth/browser-tokenreturns the session's existing JWT rather than minting a fresh one per call, so the token's expiry is not a freshness signal. A dead session returns 403, which is what the check relies on.- The cookie value changed once between weeks with no re-login recorded. Unexplained. Treat a copied cookie as a snapshot that can go stale on its own.
- The session cookie is httpOnly, so
document.cookiecannot read it. Any provisioning scheme must use a platform cookie store, or it will silently collect every cookie except the one that matters.
Decision: do not automate login
Automating it is the riskiest step in the chain and the only one needing a
stored password, against an unknown captcha_token and a lockout that would
take out the working session too. Instead assume the session can be dead before
any given match and make preflight.py routine.
Cookie provisioning to a second host remains the open problem.
Status
Unattended recording works end to end, scheduled, with nobody awake. Five matches recorded as of 2026-09-20, including one night capturing both feeds in parallel.
Verified: the token chain in plain Python; recording both feed types via
ffmpeg; Task Scheduler launching the job; concurrent readers of one token; a
dead session being detectable before kickoff; the duration cap firing at rc=0
on a feed that would not stop.
Not verified: obtaining a session from credentials. /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 unknown and probing it risks a lockout.