9c22a02fab
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
196 lines
9.1 KiB
Markdown
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.
|