Capture Bristol Rovers video pass streams for personal time-shifting

Verified end to end against a live match: 1080p H.264/AAC MPEG-TS at
~5.2 Mbit/s, recorded while the browser played the same stream.

Components:
- tampermonkey sniffer hooks fetch/XHR at document-start, captures the
  manifest URL verbatim and POSTs it to the local catcher. v0.4 also traces
  the API chain to find which call mints the token.
- catcher receives the URL and launches yt-dlp. Dedupes on the Kaltura asset
  id and checks downloader liveness, so a mid-match page reload is both safe
  and the recovery path for a dead downloader.
- creds stores secrets in the OS vault rather than a gitignored file, and
  refuses to run on a backend that does not encrypt.

Protocol notes are in the README: the stream uses a two-stage token chain
(a ~25s hdnts token for the master playlist, a ~24h hdntl token for media),
concurrency is unrestricted, and the live window is ~30s with no DVR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kyt7QHQvLJtQRuiY4wAqKB
This commit is contained in:
Mikkeli Matlock
2026-08-29 23:57:05 +09:00
commit 21113bd6ba
6 changed files with 711 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
# 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 |
|---|---|
| `tampermonkey/brfc-stream-sniffer.user.js` | Hooks fetch/XHR in the page, captures the manifest URL verbatim, POSTs it to the catcher |
| `catcher.py` | Listens on 127.0.0.1:8787, hands the URL to yt-dlp, records to `recordings/` |
| `probe.py` | Byte-exact manifest fetch + concurrency test |
## The token
Manifest URLs carry an Akamai path token:
hdntl=exp=<unix>~acl=/*~id=<n>~data=hdntl~hmac=<sig>
- `exp` -- ~24h validity
- `acl=/*` -- wildcard over the whole host, not just your match
- `hmac` -- signature over the literal path
It is a bearer credential. Anyone holding a live URL can pull the stream until
`exp`. Keep it out of pastebins and issue trackers. `stream-url.txt` and
`cookies.txt` are gitignored.
**Do not re-encode the path.** curl normalises `%2f` to `%2F`, which
invalidates the HMAC. `probe.py` sends the request target verbatim.
## Use Firefox for watching
yt-dlp lifts session cookies from the browser. Chrome 127+ on Windows uses
app-bound cookie encryption and extraction fails. Firefox works with no fuss.
set BRFC_BROWSER=firefox # default
## Workflow
pip install yt-dlp # done
python catcher.py # leave running
Install the userscript in Tampermonkey, open the match page, press play. The
sniffer catches the manifest, the catcher starts yt-dlp. Recording is MPEG-TS
(`--hls-use-mpegts`) so a killed process still leaves a playable file.
For a 3am kickoff: Task Scheduler opens the match page a few minutes before,
the browser autoplays, the sniffer fires, the catcher records. Nobody is awake.
## Concurrency check
An Akamai edge token is a stateless HMAC -- the edge validates signature and
expiry and serves; it holds no session table, so it cannot count viewers. Any
concurrent-stream limit lives in the entitlement API that *issues* tokens,
upstream of the CDN. Confirm rather than assume, while the browser is playing:
python probe.py --url "<manifest from the sniffer>" --parallel 3
3/3 alongside the playing browser means four concurrent readers of one token
and the edge does not care.
---
## Findings (2026-08-29, live 2300 JST match)
Verified against a live stream. Recorded 1080p successfully end to end.
### Two token types, not one
The player uses a **two-stage token chain**:
| Token | Where | TTL | Purpose |
|---|---|---|---|
| `hdnts=` | query string, `acl=/*` unencoded | **~25 seconds** | fetch the master playlist |
| `hdntl=` | URL path, `acl=%2f*` encoded | **~24 hours** | media playlists + segments |
The master response rewrites each variant URL with a 24h `hdntl` token. So the
`hdntl` token is the durable one worth handing to a downloader. Chasing a
`hdnts` URL by hand is pointless -- it dies before you can paste it.
### Confirmed by experiment
- **Concurrency is not limited.** Two independent ffmpeg downloads ran at full
bitrate *while Firefox played the same match*. Three concurrent readers of
one token, no complaint from the edge. Akamai token auth is a stateless HMAC
check; it has no viewer counter.
- **No CDN cookies exist.** All 13 relevant cookies are on `bristolrovers.co.uk`;
nothing is scoped to `akamaized.net`. The token in the URL is the whole auth.
- **Stream is 1920x1080 H.264 + AAC ~5.2 Mbit/s** -> roughly 4.7 GB per match.
- **Output is MPEG-TS** even though yt-dlp names it `.mp4`. That is what
`--hls-use-mpegts` buys: a killed process still leaves a playable file.
- 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
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.
### Firefox, not Chrome
yt-dlp reads cookies from a running Firefox. Chrome/Edge lock their cookie DB
while running (yt-dlp issue 7271), and an unattended job cannot close the
browser to read them.