Files
brfc-stream-pirate/README.md
T
Mikkeli Matlock 9c22a02fab Cut the README to what you need to run this
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
2026-09-26 22:33:49 +09:00

196 lines
9.1 KiB
Markdown

# 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 `hdnts` master.
`record_live.py` mints 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-ENDLIST` and 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-mpegts`
buys: 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 on `akamaized.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) and `0_z236v0e7` (audio) each recurred weeks apart.
Nothing may use it to answer "have I recorded this game" -- use the date.
`catcher.py` is 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-token` returns 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.cookie` cannot 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.