Mikkeli Matlock 847fe1e13f Choose the feed, not just the rendition
The live page can expose two entries for one match, an audio feed and a video
feed, and discovery returned whichever manifest loaded first. That race came
out differently twice on the same night: at 21:11 the page served the video
entry, at 22:30 the audio one. The scheduled run recorded audio against a video
pass, and nothing looked wrong -- it fails by producing a complete, playable,
healthy recording of the wrong thing.

--prefer cannot rescue that; it picks a rendition inside one master, after the
entry is already chosen. So add --feed, which chooses between entries:

  video (default)  prefer a video feed, fall back to audio
  audio            the reverse
  any              first discovered, the old behaviour
  both             record every feed in parallel
  --entry <id>     skip discovery and pin one

find_manifest now returns every entry it sees and keeps watching 15s past the
first hit, rather than returning on it. Each candidate is classified by the
codecs in its master, never by entry id -- ids name a feed, but relying on that
would bake in an observation that has already been wrong once.

A preference is not a filter. Non-preferred feeds stay on the list, because a
feed you hold no pass for resolves a master fine and only 403s later on the
segments; the recorder falls through to the next candidate when a download dies
immediately, so a wrong guess costs seconds rather than the match.

Output files are tagged -video / -audio, which matters once --feed both writes
two at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fr1fLMnVGNAKH5L2BmhMxn
2026-09-26 22:33:34 +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
record_live.py Unattended recorder: finds the live entry itself, mints a master, records
preflight.py Pre-match check: does the session still mint a JWT, can this host record
watchdog.py Backup starter: if nothing is progressing, kill the stale recorder and start one
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/
discover.py Drives the player with Playwright to capture the token-minting traffic
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.

Before every match

python preflight.py                   # exit 0 only if the server minted a JWT

Not optional and not a formality. The session can be dead while every local signal looks fine (see "The cookie expiry is not the session lifetime"), and the only fix -- signing in by hand in Firefox -- needs a human and some lead time. Run it hours before kickoff.

Then register the task, launch it once by hand, and stop it again before the real trigger (see "a freshly registered task can hang on its first launch").

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 (live matches, 2026-08-29 onward)

Verified against live streams, starting with a 23:00 JST kickoff on 2026-08-29 that recorded 1080p end to end. Later matches are dated where they corrected something.

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. It does not identify the match -- see below.

The entry id names the feed, not the match

Disproved 2026-09-19. Four observations, two ids:

Entry id Seen Feed
0_xm5vrrw6 2026-08-29, 2026-09-19 video
0_z236v0e7 2026-09-05, 2026-09-12 audio

Three weeks apart, the same id came back for the video feed. So the id is a channel: it distinguishes the video feed from the audio one and nothing more.

catcher.py is not broken by this, because it dedupes on liveness -- "is a downloader for this key still alive" -- and the key clears when the downloader hits its cap. But do not build anything that treats the id as a match identifier: "have I already recorded this game" cannot be answered from it, and neither can "is this a new match or a reload". Use the kickoff date for that.

Gotcha: which URL form a Python client can fetch

Earlier notes here blamed a TLS handshake difference. That was wrong. The 403 splits by token form, not by client:

URL form Python ffmpeg
index.m3u8?hdnts=... (master, query token) 200 200
hdntl=.../index_N.m3u8 (media, path token) 403 200

Consequences:

  • Mint a master URL and hand that to the downloader. record_live.py does this: it calls the entry API for a fresh master immediately before recording.
  • Force --downloader ffmpeg. yt-dlp's native HLS downloader fetches variant playlists with Python and dies on the path-token URLs. This is required, not a preference.
  • probe.py is diagnostic scaffolding and inherits the path-token 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.

Keep the duration cap. catcher.py and record_live.py pass ffmpeg_o:-t $DURATION_MIN*60. Without it, a feed that behaves like the video one above fills the disk with a logo.

This holds for audio too, which earlier notes here doubted. On 2026-09-12 the audio feed for a 20:30 kickoff went to digital silence -- volumedetect reports -91.0 dB on both mean and max, the noise floor, not a quiet passage -- at 02:35:20 into the file, and then kept serving that silence at a steady 156 kbps until the 180 min cap fired at rc=0. Nothing about the stream said it was over.

So of three observations, two feeds ran forever and one did not:

Date Feed Ended how
2026-08-29 video never; stopped manually at 116 min
2026-09-02 audio self-terminated, rc=0 at 165 min
2026-09-12 audio never; silence to the 180 min cap

The 2026-09-02 self-termination is now the outlier, not the rule, and it is the only one of the three. The cap is load-bearing on both feed types. Treat a stream that stops by itself as luck.

Budget ~36 MB/min for video (measured 2026-09-19: 1080p, 60 s -> 36 MB), so ~6.5 GB for a 180 min cap. Audio is ~1.2 MB/min, ~210 MB for the same cap.


Status

Unattended recording works. A scheduled task started record_live.py at 03:15 for an 03:45 kickoff, found the stream in 16 seconds, and recorded 165 min of audio to a playable MPEG-TS with nobody awake.

Sample sizes are small but no longer one: four recorded matches as of 2026-09-19, two video and two audio. Two earlier claims here have since been disproved outright -- the entry id does not identify the match, and the audio feed does not reliably end itself. Both are corrected above. Assume the next claim in this file is the one about to fall over.

Verified

  • Token chain end to end in plain Python: session cookie -> JWT -> entry API -> master playlist. No browser, no fingerprint forging.
  • Recording via ffmpeg, video (1080p) and audio (~122 kbps AAC).
  • Scheduled unattended run, including that Task Scheduler launches it correctly.
  • Concurrency: several readers of one token, alongside a playing browser.
  • A dead session is detectable before kickoff, cheaply and read-only (preflight.py), and distinguishable from a blocked request by the {"error":"Unauthenticated"} body and the cookie-deleting Set-Cookie.
  • The duration cap firing cleanly at rc=0 on a feed that would not stop.

Gotcha: a freshly registered task can hang on its first launch

Seen 2026-09-12. The first launch of a newly registered scheduled task hung dead at interpreter startup -- process alive, 0.015 s of CPU and 21 modules loaded after three minutes, nothing written to any log, a conhost child allocated. Not a crash and not a loop: frozen before Python ran any of our code. Killing it and launching the identical task again worked immediately and it has not recurred. Root cause unknown.

Unattended, that is a silently lost match. So after registering a task, launch it once by hand and stop it again, which makes the real firing a warm start. Stopping matters as much as starting: MultipleInstances IgnoreNew means a test run left alive will swallow the scheduled trigger without a word.

Not verified

  • Login. Everything above rides an SSESS cookie lifted from a desktop Firefox profile. Obtaining a session from credentials has never been run. /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 an unknown and testing it risks a lockout that would take out the working session too.

Disproved 2026-09-12, and this one bites. The SSESS cookie carries a ~90 day expiry, and earlier notes here read that as the session lifetime. It is not. The server can drop the session while the browser still holds a cookie that looks perfectly healthy:

SSESSaa90be30f47...  76 days left on the clock
GET /oauth/browser-token  ->  403 {"error":"Unauthenticated"}
Set-Cookie: SSESSaa90be30f47...=deleted; expires=Thu, 01 Jan 1970

That session had last worked six days earlier, at the end of the 2026-09-06 recording. Nothing local showed it: right expiry, right domain, right profile. Only asking the server revealed it.

Then the replacement session survived the next seven days fine. So the lifetime is not a week either -- it is unpredictable, and somewhere between six days and the 90 the cookie advertises.

Two consequences:

  • A local cookie check proves nothing. Presence and expiry are necessary, not sufficient. preflight.py exists for this: it spends one request asking the server to mint a JWT, which is the only answer that counts. Run it hours before kickoff, not minutes.
  • The cookie value also changed between 2026-09-12 and 2026-09-19 with no re-login recorded (48 -> 50 chars). Unexplained. Treat a copied cookie as a snapshot that can go stale on its own, which matters for provisioning one to another host.

Decision: still do not automate login, but detect it early

The old reasoning for this was arithmetic, and the arithmetic was wrong: it assumed a ~87 day cookie meant about four manual logins a year. A session that can die in six days means the refresh could be needed weekly, which is a real cost rather than a rounding error.

The decision survives the correction anyway, for the reason that was always the strong one: automating login 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. What changes is the mitigation. Do not rely on remembering to refresh it quarterly -- assume it can be dead before any given match, and make the check routine:

python preflight.py        # exit 0 only if the server minted a JWT

Cookie provisioning remains the open problem for a second host, and the rotating value above makes it harder, not easier.

Use Firefox as the session holder. Two independent reasons: its cookie DB stays readable while running (Chrome/Edge lock theirs), and it holds the login durably where Chrome has been observed dropping it within a day.

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