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

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.

The stream never ends

After full time the feed switches to a static league slate and keeps serving indefinitely at full CBR bitrate (~4.7 Mbit/s to encode a still image). There 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.

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.

S
Description
pirating the pirates
Readme 106 KiB
Languages
Python 82.7%
JavaScript 14.9%
Shell 2.4%