Compare commits

..

4 Commits

Author SHA1 Message Date
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
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
Mikkeli Matlock c39de9a017 Correct three claims that three weeks of matches disproved
The cookie expiry is not the session lifetime. A session died six days into a
cookie with 76 days left on it, returning 403 {"error":"Unauthenticated"} and
a Set-Cookie that deleted the thing. Nothing local showed it. The replacement
then survived seven days, so the lifetime is not a week either -- it is
unpredictable, and only the server can answer. The old reasoning for not
automating login was arithmetic built on the 90 day figure; the decision
survives on its real merits, but the mitigation changes from refreshing
quarterly to checking before every match.

The entry id names the feed, not the match. 0_xm5vrrw6 came back three weeks
apart for video, 0_z236v0e7 twice for audio. catcher.py is unaffected -- it
dedupes on liveness, not identity -- but nothing should ask it "have I
recorded this game".

The audio feed does not reliably end itself. On 2026-09-12 it went to digital
silence at -91 dB and served that silence to the 180 min cap. The one audio
stream that self-terminated is now the outlier of three. The cap is
load-bearing on both feed types.

Also records the scheduled-task startup hang and the warm-start habit it
forces, and the per-minute sizes measured for both feeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fr1fLMnVGNAKH5L2BmhMxn
2026-09-19 21:48:10 +09:00
Mikkeli Matlock 14ee5ec26c Add a pre-match session check and a recovery watchdog
Two failure modes cost a match silently, and neither was detectable from
inside the recorder.

preflight.py: the session can be dead while every local signal looks fine --
right cookie, right expiry, right profile. Only the server knows. This spends
one request asking it to mint a JWT and exits non-zero if it will not, so a
dead login surfaces hours before kickoff instead of at it. Reports cookies and
tokens by length; it never prints a value.

watchdog.py: a scheduled launch has been seen to hang at interpreter startup,
alive but frozen before any of our code runs. So "is a recorder process
running" is the wrong question -- the hung process answers yes. This asks
whether recording is *progressing*, via a growing .ts or a freshly written
log, and only intervenes when both signals are stale. A healthy capture is
never interrupted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fr1fLMnVGNAKH5L2BmhMxn
2026-09-19 21:47:19 +09:00
6 changed files with 715 additions and 204 deletions
+1
View File
@@ -5,3 +5,4 @@ cookies.txt
*.mkv *.mkv
*.mp4 *.mp4
*.ts *.ts
__pycache__/
+154 -144
View File
@@ -11,175 +11,185 @@ makes, using your own paid session.
| File | Job | | File | Job |
|---|---| |---|---|
| `tampermonkey/brfc-stream-sniffer.user.js` | Hooks fetch/XHR in the page, captures the manifest URL verbatim, POSTs it to the catcher | | `record_live.py` | Unattended recorder: finds the live entry, mints a master, records |
| `catcher.py` | Listens on 127.0.0.1:8787, hands the URL to yt-dlp, records to `recordings/` | | `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 | | `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 ## The token
Manifest URLs carry an Akamai path token: Manifest URLs carry an Akamai path token:
hdntl=exp=<unix>~acl=/*~id=<n>~data=hdntl~hmac=<sig> hdntl=exp=<unix>~acl=/*~id=<n>~data=hdntl~hmac=<sig>
- `exp` -- ~24h validity It is a **bearer credential** with `acl=/*` over the whole host, valid to `exp`.
- `acl=/*` -- wildcard over the whole host, not just your match Anyone holding a live URL can pull the stream. Keep it out of pastebins and
- `hmac` -- signature over the literal path issue trackers; `stream-url.txt` and `cookies.txt` are gitignored.
It is a bearer credential. Anyone holding a live URL can pull the stream until Two tokens, not one:
`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 | Token | Where | TTL | Fetches |
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 | | `hdnts=` | query string, `acl=/*` unencoded | ~25 seconds | master playlist |
| `hdntl=` | URL path, `acl=%2f*` encoded | **~24 hours** | media playlists + segments | | `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 The master response rewrites each variant URL with a 24h `hdntl`. Chasing a
`hdntl` token is the durable one worth handing to a downloader. Chasing a `hdnts` URL by hand is pointless -- it expires before you can paste it.
`hdnts` URL by hand is pointless -- it dies before you can paste it.
### Confirmed by experiment **Do not re-encode the path.** curl normalises `%2f` to `%2F`, invalidating the
HMAC. `probe.py` sends the request target verbatim.
- **Concurrency is not limited.** Two independent ffmpeg downloads ran at full ## Rules that are not preferences
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: which URL form a Python client can fetch - **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.
Earlier notes here blamed a TLS handshake difference. That was wrong. The 403 ## Facts
splits by **token form**, not by client:
| URL form | Python | ffmpeg | - 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`
| `index.m3u8?hdnts=...` (master, query token) | 200 | 200 | buys: a killed process still leaves a playable file.
| `hdntl=.../index_N.m3u8` (media, path token) | 403 | 200 | - 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.
Consequences: ## The session
- Mint a **master** URL and hand that to the downloader. `record_live.py` does The `SSESS` cookie is the whole login. Everything downstream -- JWT, entry API,
this: it calls the entry API for a fresh master immediately before recording. master playlist -- rides it, in plain Python, with no browser or fingerprint
- Force `--downloader ffmpeg`. yt-dlp's native HLS downloader fetches variant forging.
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 **Cookie expiry is not session lifetime.** The server can drop a session while
the browser holds a cookie that looks perfectly healthy:
yt-dlp reads cookies from a running Firefox. Chrome/Edge lock their cookie DB SSESS... 76 days left on the clock
while running (yt-dlp issue 7271), and an unattended job cannot close the GET /oauth/browser-token -> 403 {"error":"Unauthenticated"}
browser to read them. Set-Cookie: SSESS...=deleted; expires=Thu, 01 Jan 1970
### The stream never ends 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.
After full time the feed switches to a static league slate and keeps serving Other session behaviour:
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 - **Sessions are concurrent.** Logging in via a second browser does not
`ffmpeg_o:-t $DURATION_MIN*60`. Without it, a feed that behaves like the video invalidate the first -- Drupal issues a session per browser. So a second
one above fills the disk with a logo. device can hold its own session without disturbing the recorder's. *One
observation, checked immediately after the second login.*
Note the sample size. This is **one video observation**, and it is contradicted - `/oauth/browser-token` returns the session's **existing** JWT rather than
by **one audio observation**: on 2026-09-02 an audio stream ended cleanly by minting a fresh one per call, so the token's expiry is not a freshness
itself (`rc=0` at 165 min, against a 180 min cap that never fired). So "the signal. A dead session returns 403, which is what the check relies on.
stream never ends" is a property of that video feed, not an established rule. - The cookie *value* changed once between weeks with no re-login recorded.
Treat the cap as a safety net rather than as the only stop condition. 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
Observed: video, 116 min recorded for a 23:00 kickoff, still running at 01:04 provisioning scheme must use a platform cookie store, or it will silently
and stopped manually. Audio, 165 min, self-terminated. Budget ~3.5-4 GB per copy per match at 40 MB/min. collect every cookie except the one that matters.
---
## 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 one. One video capture, one audio capture. Where the two
disagree (stream termination, whether an entry id persists between matches),
neither is established -- do not build on either without more matches.
### 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.
### 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.
### Decision: do not automate login ### Decision: do not automate login
The cookie lasts ~87 days and matches are weekly, so automating login saves Automating it is the riskiest step in the chain and the only one needing a
about four manual actions a year -- while being the riskiest step and the only stored password, against an unknown `captcha_token` and a lockout that would
one needing a stored password. Refresh the cookie by logging in manually on the take out the working session too. Instead assume the session can be dead before
desktop each quarter and provisioning it to the recording host instead. That any given match and make `preflight.py` routine.
makes cookie *provisioning* the open problem, not the captcha.
Use Firefox as the session holder. Two independent reasons: its cookie DB stays Cookie *provisioning* to a second host remains the open problem.
readable while running (Chrome/Edge lock theirs), and it holds the login
durably where Chrome has been observed dropping it within a day. ## 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.
+4 -2
View File
@@ -32,8 +32,10 @@ BROWSER = os.environ.get("BRFC_BROWSER", "firefox")
# ends cleanly. Pad past 90 min for half-time, stoppage and post-match. # ends cleanly. Pad past 90 min for half-time, stoppage and post-match.
DURATION_MIN = int(os.environ.get("BRFC_DURATION_MIN", "150")) DURATION_MIN = int(os.environ.get("BRFC_DURATION_MIN", "150"))
# Kaltura entry id (e.g. 0_xm5vrrw6) identifies the match. Every manifest for # Kaltura entry id (e.g. 0_xm5vrrw6). Every manifest for one game -- master and
# one game -- master and each variant, each with its own token -- shares it. # each variant, each with its own token -- shares it. It names the *feed*, not
# the match: the same id came back three weeks apart (see README). That is fine
# here, because the dedupe below is on liveness, not on "seen this before".
ASSET_RE = re.compile(r"/(\d+_[a-z0-9]+)/", re.I) ASSET_RE = re.compile(r"/(\d+_[a-z0-9]+)/", re.I)
_procs = {} # asset key -> Popen of the running downloader _procs = {} # asset key -> Popen of the running downloader
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Pre-match check: is the session still good, and can this host record?
Run it a few hours before kickoff, not five minutes before. The one thing it
cannot fix for you is a dead login: refreshing that means opening Firefox and
signing in by hand, which takes a human (see README, "Decision: do not
automate login").
python preflight.py # session + host checks
python preflight.py --entry 0_z236v0e7 # also exercise the entry API
Exit code is 0 only if the server returned a JWT for this session. Anything
else means tonight's scheduled run would fail, and it would fail at kickoff
with nobody watching.
Prints no secrets. Cookie values and tokens are reported by length only.
"""
import argparse
import base64
import contextlib
import http.cookiejar
import io
import json
import os
import re
import shutil
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
ROOT = os.path.dirname(os.path.abspath(__file__))
RECORDINGS = os.path.join(ROOT, "recordings")
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) "
"Gecko/20100101 Firefox/131.0")
TOKEN_URL = ("https://www.bristolrovers.co.uk/oauth/browser-token"
"?client_id=streamamg&token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token")
# Public client key, shipped to every browser -- not a secret.
API_KEY = "50ivbQ3IXpF2mwT4tPAJ2V6cVm4CGRT2YKmDK5v6"
OK, BAD, WARN = "ok ", "FAIL", "warn"
def say(status, msg):
print(f"[{status}] {msg}", flush=True)
def check_deps():
"""Everything record_live.py reaches for, checked before kickoff."""
good = True
for mod, hint in (("yt_dlp", "pip install yt-dlp"),
("playwright", "pip install playwright && "
"python -m playwright install firefox")):
try:
__import__(mod)
say(OK, f"{mod} importable")
except ImportError:
say(BAD, f"{mod} missing -- {hint}")
good = False
if shutil.which("ffmpeg"):
say(OK, "ffmpeg on PATH")
else:
say(BAD, "ffmpeg not on PATH -- the downloader is forced to ffmpeg")
good = False
return good
def check_disk(need_gb=8):
"""A video match is ~4.7 GB. Audio is ~200 MB. Budget for video."""
os.makedirs(RECORDINGS, exist_ok=True)
free_gb = shutil.disk_usage(RECORDINGS).free / 2**30
if free_gb >= need_gb:
say(OK, f"{free_gb:.0f} GB free on the recordings volume")
return True
say(BAD, f"only {free_gb:.1f} GB free, want >= {need_gb} GB for a video match")
return False
def firefox_jar():
from yt_dlp.cookies import extract_cookies_from_browser
with contextlib.redirect_stderr(io.StringIO()):
return extract_cookies_from_browser("firefox")
def check_cookies(jar):
"""Cookie *present and unexpired* is necessary, not sufficient. The server
can invalidate a session while the browser still holds the cookie, which is
exactly what check_session() is for."""
brfc = [c for c in jar if "bristolrovers" in (c.domain or "").lower()]
say(OK if brfc else BAD, f"{len(brfc)} bristolrovers cookies in the Firefox jar")
sess = [c for c in brfc if c.name.startswith(("SSESS", "SESS"))]
if not sess:
say(BAD, "no SSESS/SESS cookie -- Firefox is not logged in")
return brfc, False
for c in sess:
if not c.expires:
say(WARN, f"{c.name[:16]}... is a session cookie (dies with Firefox)")
continue
days = (c.expires - time.time()) / 86400
say(OK if days > 1 else BAD,
f"{c.name[:16]}... len={len(c.value or '')} expires in {days:.0f}d")
return brfc, True
def check_session(brfc):
"""The real login test: will the server hand back a JWT for this cookie?
It returns the session's existing token rather than minting a fresh one per
call, so the expiry printed below is the token's remaining life and not a
freshness signal. That does not weaken the check: a dead session answers
403, which is the thing we are looking for.
"""
cj = http.cookiejar.CookieJar()
for c in brfc:
cj.set_cookie(c)
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
hdrs = {"User-Agent": UA, "Accept": "application/json, text/plain, */*"}
try:
resp = op.open(urllib.request.Request(TOKEN_URL, headers=hdrs), timeout=20)
body = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:200]
say(BAD, f"browser-token returned HTTP {exc.code}: {detail}")
if exc.code in (401, 403):
say(BAD, "session is dead server-side -- sign in again in Firefox")
return None
except Exception as exc:
say(BAD, f"browser-token unreachable: {type(exc).__name__}: {exc}")
return None
m = re.search(r"(eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+)", body)
if not m:
say(BAD, "no JWT in the browser-token response -- session is stale")
return None
jwt = m.group(1)
seg = jwt.split(".")[1]
try:
claims = json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4)))
except Exception:
claims = {}
exp = claims.get("exp")
when = (f", expires {datetime.fromtimestamp(exp, timezone.utc):%H:%M:%S}Z "
f"({(exp - time.time()) / 60:+.0f} min)" if exp else "")
say(OK, f"session is live, server returned a JWT ({len(jwt)} chars{when})")
return jwt
def check_entry(jwt, entry_id):
"""Optional: exercise the rest of the chain against a known entry id.
A master playlist outlives its stream, so a 200 here does not prove the
match is live -- it proves the entitlement still resolves.
"""
req = urllib.request.Request(
f"https://api.playback.streamamg.com/v1/entry/{entry_id}",
headers={"User-Agent": UA, "Authorization": "Bearer " + jwt,
"x-api-key": API_KEY,
"Origin": "https://www.bristolrovers.co.uk",
"Referer": "https://www.bristolrovers.co.uk/"})
try:
data = json.loads(urllib.request.urlopen(req, timeout=20)
.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as exc:
say(WARN, f"entry {entry_id}: HTTP {exc.code} "
f"(stale entry id, or not entitled)")
return
except Exception as exc:
say(WARN, f"entry {entry_id}: {type(exc).__name__}: {exc}")
return
hls = (data.get("media") or {}).get("hls")
if not hls:
say(WARN, f"entry {entry_id} resolved but carries no HLS master")
return
say(OK, f"entry {entry_id} resolves to a master playlist")
try:
body = urllib.request.urlopen(urllib.request.Request(
hls, headers={"User-Agent": UA,
"Referer": "https://www.bristolrovers.co.uk/"}),
timeout=15).read().decode("utf-8", "replace")
except Exception as exc:
say(WARN, f"could not read the master: {type(exc).__name__}: {exc}")
return
PREFIX = "#EXT-X-STREAM-INF:"
streams = [ln[len(PREFIX):] for ln in body.splitlines()
if ln.startswith(PREFIX)]
say(OK, f"master offers {len(streams)} rendition(s):")
for st in streams:
bw = re.search(r"BANDWIDTH=(\d+)", st)
res = re.search(r"RESOLUTION=([\dx]+)", st)
cod = re.search(r'CODECS="([^"]+)"', st)
codecs = cod.group(1) if cod else "?"
kind = "audio-only" if ("avc" not in codecs and "hvc" not in codecs) else "video"
print(f" {kind:11s} {int(bw.group(1)) // 1000 if bw else '?':>6} kbps "
f"{res.group(1) if res else '-':>10} {codecs}")
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--entry", help="entry id to exercise the entry API against")
args = ap.parse_args()
print(f"preflight {datetime.now():%Y-%m-%d %H:%M:%S}\n")
host_ok = check_deps() & check_disk()
try:
jar = firefox_jar()
except Exception as exc:
say(BAD, f"cannot read Firefox cookies: {type(exc).__name__}: {exc}")
return 1
brfc, have_cookie = check_cookies(jar)
jwt = check_session(brfc) if have_cookie else None
if jwt and args.entry:
check_entry(jwt, args.entry)
print()
if not jwt:
say(BAD, "NOT READY -- sign in at https://www.bristolrovers.co.uk/user/login "
"in Firefox, then run this again")
return 1
if not host_ok:
say(BAD, "session is good but this host cannot record -- fix the above")
return 1
say(OK, "READY")
return 0
if __name__ == "__main__":
sys.exit(main())
+192 -53
View File
@@ -13,6 +13,16 @@ outlive kickoff by months.
# start now, keep looking for up to 45 min, then record 150 min # start now, keep looking for up to 45 min, then record 150 min
python record_live.py --max-wait 45 --duration 150 python record_live.py --max-wait 45 --duration 150
# take both feeds at once, or pin one when you know which you hold
python record_live.py --feed both
python record_live.py --entry 0_xm5vrrw6
Two different choices, often confused. **--feed** picks which *entry* to record
when the page exposes more than one for the same match, typically an audio feed
and a video feed. **--prefer** picks a rendition *inside* one entry's master.
Getting --feed wrong cannot be rescued by --prefer, and it fails quietly: you
get a complete, playable, perfectly healthy recording of the wrong thing.
# or wait until a wall-clock time first (03:15 before an 03:45 kickoff) # or wait until a wall-clock time first (03:15 before an 03:45 kickoff)
python record_live.py --at "2026-09-02 03:15" --max-wait 60 python record_live.py --at "2026-09-02 03:15" --max-wait 60
@@ -70,9 +80,13 @@ TOKEN_URL = ("https://www.bristolrovers.co.uk/oauth/browser-token"
API_KEY = "50ivbQ3IXpF2mwT4tPAJ2V6cVm4CGRT2YKmDK5v6" API_KEY = "50ivbQ3IXpF2mwT4tPAJ2V6cVm4CGRT2YKmDK5v6"
def mint_master(entry_id): def mint_master(entry_id, quiet=False):
"""Mint a fresh master playlist URL in plain Python. No browser. """Mint a fresh master playlist URL in plain Python. No browser.
Returns (master_url, rendition_kinds). The master token lives ~25 seconds,
so mint immediately before you use the URL -- classify with one call, then
mint again for the recording.
Handing yt-dlp the *media* playlist (hdntl, path token) fails: Python Handing yt-dlp the *media* playlist (hdntl, path token) fails: Python
clients get 403 on that URL form, and yt-dlp fetches the playlist with clients get 403 on that URL form, and yt-dlp fetches the playlist with
urllib before ffmpeg ever runs. The *master* (hdnts, query token) is fetched urllib before ffmpeg ever runs. The *master* (hdnts, query token) is fetched
@@ -96,7 +110,7 @@ def mint_master(entry_id):
m = re.search(r"(eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+)", body) m = re.search(r"(eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+)", body)
if not m: if not m:
log(" mint: no JWT in browser-token response (session expired?)") log(" mint: no JWT in browser-token response (session expired?)")
return None return None, []
jwt = m.group(1) jwt = m.group(1)
req = urllib.request.Request( req = urllib.request.Request(
@@ -107,14 +121,17 @@ def mint_master(entry_id):
data = json.loads(urllib.request.urlopen(req, timeout=20) data = json.loads(urllib.request.urlopen(req, timeout=20)
.read().decode("utf-8", "replace")) .read().decode("utf-8", "replace"))
hls = (data.get("media") or {}).get("hls") or None hls = (data.get("media") or {}).get("hls") or None
if hls: kinds = log_renditions(hls, quiet=quiet) if hls else []
log_renditions(hls) return hls, kinds
return hls
def log_renditions(master_url): def log_renditions(master_url, quiet=False):
"""Record what the master offers. We have never seen one carrying both """Log what the master offers, and return the kind of each rendition.
video and audio, so log it every time until we know how that looks."""
The return value is what feed_kind() classifies an entry on. Codecs are the
only trustworthy signal: entry ids look alike, and the same id comes back
week after week for the same feed.
"""
import urllib.request import urllib.request
try: try:
req = urllib.request.Request(master_url, headers={"User-Agent": UA, req = urllib.request.Request(master_url, headers={"User-Agent": UA,
@@ -122,10 +139,12 @@ def log_renditions(master_url):
body = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "replace") body = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "replace")
except Exception as exc: except Exception as exc:
log(f" (could not read master for rendition list: {exc})") log(f" (could not read master for rendition list: {exc})")
return return []
PREFIX = "#EXT-X-STREAM-INF:" PREFIX = "#EXT-X-STREAM-INF:"
streams = [ln[len(PREFIX):] for ln in body.splitlines() streams = [ln[len(PREFIX):] for ln in body.splitlines()
if ln.startswith(PREFIX)] if ln.startswith(PREFIX)]
kinds = []
if not quiet:
log(f" master offers {len(streams)} rendition(s):") log(f" master offers {len(streams)} rendition(s):")
for st in streams: for st in streams:
bw = re.search(r"BANDWIDTH=(\d+)", st) bw = re.search(r"BANDWIDTH=(\d+)", st)
@@ -133,20 +152,38 @@ def log_renditions(master_url):
cod = re.search(r'CODECS="([^"]+)"', st) cod = re.search(r'CODECS="([^"]+)"', st)
codecs = cod.group(1) if cod else "?" codecs = cod.group(1) if cod else "?"
kind = "audio-only" if ("avc" not in codecs and "hvc" not in codecs) else "video" kind = "audio-only" if ("avc" not in codecs and "hvc" not in codecs) else "video"
kinds.append(kind)
if not quiet:
log(f" {kind:11s} {int(bw.group(1))//1000 if bw else '?':>6} kbps " log(f" {kind:11s} {int(bw.group(1))//1000 if bw else '?':>6} kbps "
f"{res.group(1) if res else '-':>10} {codecs}") f"{res.group(1) if res else '-':>10} {codecs}")
return kinds
def find_manifest(timeout_s=90): def feed_kind(kinds):
"""Load the live page headlessly and return the entry id, or None. """'video' if the master carries any video rendition, else 'audio'."""
if not kinds:
return "unknown"
return "video" if any(k == "video" for k in kinds) else "audio"
def find_manifest(timeout_s=90, settle_s=15):
"""Load the live page headlessly and return every entry id it exposes.
The browser is needed only for this: the entry id appears at runtime, and The browser is needed only for this: the entry id appears at runtime, and
the static HTML carries none when nothing is live. Everything after this the static HTML carries none when nothing is live. Everything after this
is plain HTTP. is plain HTTP.
Returns a list, not one id, because the page can expose more than one feed
for the same match -- an audio entry and a video entry. Which manifest
loads first is a race, and it has been observed to come out differently on
the same night. Returning the first one silently records the wrong feed.
So keep watching for `settle_s` after the first hit, and let the caller
choose.
""" """
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
found = {"master": None, "media": None} entries = [] # discovery order, de-duplicated
first_seen = [None]
with sync_playwright() as p: with sync_playwright() as p:
browser = p.firefox.launch(headless=True) browser = p.firefox.launch(headless=True)
@@ -159,10 +196,14 @@ def find_manifest(timeout_s=90):
u = resp.url u = resp.url
if ".m3u8" not in u.split("?")[0].lower(): if ".m3u8" not in u.split("?")[0].lower():
return return
if "hdntl=" in u and not found["media"]: if "hdntl=" not in u and "hdnts=" not in u:
found["media"] = u return
elif "hdnts=" in u and not found["master"]: m = re.search(r"/(\d+_[a-z0-9]+)/", u, re.I)
found["master"] = u if not m or m.group(1) in entries:
return
entries.append(m.group(1))
if first_seen[0] is None:
first_seen[0] = time.time()
page.on("response", on_response) page.on("response", on_response)
try: try:
@@ -176,7 +217,11 @@ def find_manifest(timeout_s=90):
# because a stream that has only just gone live may need the nudge. # because a stream that has only just gone live may need the nudge.
deadline = time.time() + timeout_s deadline = time.time() + timeout_s
clicked = False clicked = False
while time.time() < deadline and not found["media"]: while time.time() < deadline:
# Stop once the page has settled: a feed the player never requests
# will not appear no matter how long we wait.
if first_seen[0] and time.time() - first_seen[0] > settle_s:
break
if not clicked: if not clicked:
for sel in (".bmpui-ui-hugeplaybacktogglebutton", for sel in (".bmpui-ui-hugeplaybacktogglebutton",
".bmpui-ui-playbacktogglebutton", ".bmpui-ui-playbacktogglebutton",
@@ -196,16 +241,13 @@ def find_manifest(timeout_s=90):
browser.close() browser.close()
seen_url = found["media"] or found["master"] return entries
if not seen_url:
return None
m = re.search(r"/(\d+_[a-z0-9]+)/", seen_url, re.I)
return m.group(1) if m else None
def record(url, duration_min, prefer='best'): def record(url, duration_min, prefer='best', tag=None):
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = os.path.join(RECORDINGS, f"brfc-{stamp}.ts") name = f"brfc-{stamp}-{tag}.ts" if tag else f"brfc-{stamp}.ts"
out = os.path.join(RECORDINGS, name)
os.makedirs(RECORDINGS, exist_ok=True) os.makedirs(RECORDINGS, exist_ok=True)
logfile = os.path.join(LOGS, f"yt-dlp-{stamp}.log") logfile = os.path.join(LOGS, f"yt-dlp-{stamp}.log")
cmd = [ cmd = [
@@ -239,6 +281,99 @@ def record(url, duration_min, prefer='best'):
return subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT) return subprocess.Popen(cmd, stdout=fh, stderr=subprocess.STDOUT, cwd=ROOT)
def classify_entries(entries):
"""Label each discovered entry video or audio, by minting its master once.
Returns [(entry_id, kind)] in discovery order, skipping anything that will
not mint. The master minted here is thrown away -- its token lives ~25
seconds, so the recording mints its own.
"""
out = []
for entry in entries:
try:
url, kinds = mint_master(entry)
except Exception as exc:
log(f" {entry}: mint failed: {type(exc).__name__}: {exc}")
continue
if not url:
continue
kind = feed_kind(kinds)
log(f" {entry} is a {kind} feed")
out.append((entry, kind))
return out
def choose_feeds(chosen, want):
"""Order classified entries by preference. 'both' and 'any' take them all.
A preference is not a filter: the non-preferred feeds stay on the list as
fallbacks, because a feed you hold no pass for still resolves a master and
only fails later, on the segments.
"""
if want in ("both", "any"):
return chosen
first = [c for c in chosen if c[1] == want]
rest = [c for c in chosen if c[1] != want]
if not first and rest:
log(f" no {want} feed on offer -- falling back to "
f"{', '.join(k for _, k in rest)}")
return first + rest
def run_recorders(targets, args):
"""Record, and report whether a recording actually ran.
'both' starts every feed at once: concurrency is not limited and a second
copy is cheap insurance. Otherwise take the first choice and fall through
to the next only when the downloader dies immediately -- which is what an
unentitled feed looks like, the master resolving and the segments 403ing.
"""
def start(entry, kind):
try:
url, _ = mint_master(entry, quiet=True)
except Exception as exc:
log(f" {entry}: mint failed: {type(exc).__name__}: {exc}")
return None
if not url:
log(f" {entry}: could not re-mint a master; skipping")
return None
proc = record(url, args.duration, args.prefer, tag=kind)
log(f" {entry} ({kind}): downloader pid {proc.pid}")
return proc, time.time()
def finish(entry, kind, proc, t0):
proc.wait()
ran = time.time() - t0
log(f" {entry} ({kind}): rc={proc.returncode} after {ran:.0f}s")
# A master outlives its stream, so minting one proves nothing about
# whether the match is up. Only a download that actually ran does.
if proc.returncode == 0 or ran > 120:
return True
log(" died immediately -- not live yet, or no pass for this feed")
return False
if args.feed == "both":
started = []
for entry, kind in targets:
s = start(entry, kind)
if s is not None:
started.append((entry, kind) + s)
# Every one must be waited on, so collect results before reducing --
# any() would short-circuit and orphan a running downloader.
results = [finish(*row) for row in started]
return any(results)
# One feed at a time, falling through on immediate death so an unentitled
# first choice does not cost the match.
for entry, kind in targets:
s = start(entry, kind)
if s is None:
continue
if finish(entry, kind, *s):
return True
return False
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--at", help='wall-clock start, e.g. "2026-09-02 03:15"') ap.add_argument("--at", help='wall-clock start, e.g. "2026-09-02 03:15"')
@@ -250,6 +385,15 @@ def main():
ap.add_argument("--prefer", choices=("best", "audio", "video"), default="best", ap.add_argument("--prefer", choices=("best", "audio", "video"), default="best",
help="which rendition to take when the master offers both; " help="which rendition to take when the master offers both; "
"'best' is yt-dlp's default and prefers video") "'best' is yt-dlp's default and prefers video")
ap.add_argument("--entry", help="skip discovery and record this entry id, "
"bypassing --feed entirely")
ap.add_argument("--feed", choices=("video", "audio", "any", "both"),
default="video",
help="which FEED to record when the page exposes more than "
"one entry. 'video' prefers a video feed and falls "
"back to audio; 'both' records every feed in parallel. "
"Distinct from --prefer, which picks a rendition "
"inside one feed and cannot rescue a wrong feed.")
args = ap.parse_args() args = ap.parse_args()
if args.at: if args.at:
@@ -263,42 +407,37 @@ def main():
attempt = 0 attempt = 0
while time.time() < deadline: while time.time() < deadline:
attempt += 1 attempt += 1
if args.entry:
entries = [args.entry]
log(f"attempt {attempt}: pinned entry {args.entry}")
else:
log(f"attempt {attempt}: looking for a live stream") log(f"attempt {attempt}: looking for a live stream")
try: try:
entry_id = find_manifest() entries = find_manifest()
except Exception as exc: except Exception as exc:
log(f" error: {type(exc).__name__}: {exc}") log(f" error: {type(exc).__name__}: {exc}")
entry_id = None entries = []
if entry_id:
log(f" live entry: {entry_id}")
try:
url = mint_master(entry_id)
except Exception as exc:
log(f" mint failed: {type(exc).__name__}: {exc}")
url = None
if not url:
log(" could not mint a manifest; retrying")
time.sleep(args.poll)
continue
log(" minted fresh master playlist")
proc = record(url, args.duration, args.prefer)
log(f" downloader pid {proc.pid}; waiting for it to finish")
t0 = time.time()
proc.wait()
ran = time.time() - t0
log(f" downloader exited rc={proc.returncode} after {ran:.0f}s")
# A master playlist outlives its stream: after a match ends the if not entries:
# master is still served while its variants 404. So "found a log(f" nothing live yet; retrying in {args.poll}s")
# master" does not mean "stream is live" -- only a download that
# actually ran does. Anything that dies immediately means the
# stream is not up yet, so keep waiting rather than giving up.
if proc.returncode == 0 or ran > 120:
return 0
log(" died immediately -- stream not live yet, will retry")
time.sleep(args.poll) time.sleep(args.poll)
continue continue
log(f" nothing live yet; retrying in {args.poll}s")
log(f" live entries: {', '.join(entries)}")
chosen = classify_entries(entries)
if not chosen:
log(" could not mint a manifest for any entry; retrying")
time.sleep(args.poll)
continue
targets = choose_feeds(chosen, args.feed)
if not targets:
log(f" no {args.feed} feed among {[k for _, k in chosen]}; retrying")
time.sleep(args.poll)
continue
if run_recorders(targets, args) or time.time() >= deadline:
return 0
time.sleep(args.poll) time.sleep(args.poll)
log("gave up: no live stream appeared within the wait window") log("gave up: no live stream appeared within the wait window")
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Backup starter: confirm something is actually recording, else start it.
Schedule this a few minutes before kickoff, behind the primary recording task.
It exists because a scheduled launch of record_live.py has been seen to hang
dead at interpreter startup -- process alive, zero CPU, nothing logged, forever.
Unattended, that loses the match silently.
So "is a recorder process running" is the wrong question: the hung process
answers yes. This asks whether recording is *progressing*:
1. a .ts in recordings/ grew in the last --quiet-secs -> healthy, leave it
2. record_live.log was written in the last --quiet-secs -> mid search loop,
leave it
3. neither -> stale. Kill any
recorder process
and start a fresh
one.
Killing only ever targets a recorder that has gone quiet on both signals, so a
healthy capture is never interrupted.
python watchdog.py --duration 170 --max-wait 55
"""
import argparse
import glob
import os
import subprocess
import sys
import time
from datetime import datetime
ROOT = os.path.dirname(os.path.abspath(__file__))
RECORDINGS = os.path.join(ROOT, "recordings")
LOGS = os.path.join(ROOT, "logs")
RECORDER = os.path.join(ROOT, "record_live.py")
def log(msg):
line = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}"
print(line, flush=True)
os.makedirs(LOGS, exist_ok=True)
with open(os.path.join(LOGS, "watchdog.log"), "a", encoding="utf-8") as fh:
fh.write(line + chr(10))
def powershell(cmd):
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command", cmd],
capture_output=True, text=True, timeout=60)
return out.stdout.strip()
except Exception as exc:
log(f" powershell failed: {type(exc).__name__}: {exc}")
return ""
def recorder_pids():
"""PIDs of any running record_live.py, hung or healthy."""
out = powershell(
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
"Where-Object { $_.CommandLine -match 'record_live' } | "
"ForEach-Object { $_.ProcessId }")
return [int(x) for x in out.split() if x.strip().isdigit()]
def newest_mtime(pattern):
files = glob.glob(pattern)
return max((os.path.getmtime(f) for f in files), default=0)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--duration", type=int, default=170,
help="recording cap in minutes for a run we start")
ap.add_argument("--max-wait", type=int, default=55,
help="minutes to keep looking for a live stream")
ap.add_argument("--prefer", choices=("best", "audio", "video"), default="best")
ap.add_argument("--quiet-secs", type=int, default=180,
help="how stale both signals must be before we intervene")
args = ap.parse_args()
log("watchdog: checking whether anything is recording")
now = time.time()
ts_age = now - newest_mtime(os.path.join(RECORDINGS, "*.ts"))
log_age = now - newest_mtime(os.path.join(LOGS, "record_live.log"))
pids = recorder_pids()
log(f" recorder pids {pids or 'none'}; newest .ts {ts_age:.0f}s old; "
f"record_live.log {log_age:.0f}s old")
if ts_age < args.quiet_secs:
log(" a recording is growing -- healthy, nothing to do")
return 0
if pids and log_age < args.quiet_secs:
log(" recorder is logging -- mid search loop, nothing to do")
return 0
if pids:
log(f" recorder {pids} is alive but both signals are stale -- killing it")
for pid in pids:
powershell(f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue")
time.sleep(3)
still = recorder_pids()
if still:
log(f" WARNING: {still} survived the kill; starting anyway")
else:
log(" nothing is recording")
cmd = [sys.executable, RECORDER,
"--max-wait", str(args.max_wait),
"--duration", str(args.duration),
"--prefer", args.prefer]
log(f" starting: {' '.join(cmd[1:])}")
# Detached: the watchdog task exits immediately, the recorder outlives it.
flags = 0
if os.name == "nt":
flags = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(
subprocess, "DETACHED_PROCESS", 0)
proc = subprocess.Popen(cmd, cwd=ROOT, creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
log(f" started recorder pid {proc.pid}")
return 0
if __name__ == "__main__":
sys.exit(main())