Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
8.8 KiB
TG4 module — filling in module.yaml
Brightcove Live: HLS manifests and a raw Widevine license. The playback catalog is public, so channels resolve without a phone once you have the account id and policy key. Phone capture also works and is the only route for channels the catalog does not list.
Create apps/modules/tg4/module.yaml. It is gitignored. None of these values ship
in the repo.
The DRM stack
| Piece | Detail |
|---|---|
| Manifest | HLS .m3u8 (prefer the DVR playlist) |
| License | POST a raw binary challenge to .../lic/wv?token=<JWT> |
| License auth | JWT in the query string, not a header |
| Key mode | raw (not the JSON ModularDrm wrapper) |
| Keys per stream | several — a multi-KID SAMPLE-AES/cbcs stream needs all of them |
| PSSH | from the HLS playlist's Widevine KEYFORMAT line, not from a manifest |
Also published on the same host: FairPlay (/lic/fp) and PlayReady (/lic/pr).
Skeleton
package: <android package id>
proxy_bin: bin/proxy-android-arm64
ca_hash: "6c3578b4"
account_id: "<brightcove account id>"
policy_key: "BCpkAD..."
playback_config_url: "https://<host>/<path>/playback_config.json"
score:
hosts:
- <live CDN host>
deny:
- <analytics host fragment>
aliases:
main: chanone
plus1: plus
channels:
chanone:
label: "Main"
video_id: "<brightcove video id>"
stream_id_field: <field in playback_config holding that id>
token_field: <field in playback_config holding the playback JWT>
live_card_index: 0
kids:
label: "Kids"
video_id: "<brightcove video id>"
token_field: <field name>
live_card_index: 1
sport:
label: "Sport"
phone_only: true # no catalog entry; capture from the phone
live_card_index: 3
ui:
launch_resource_contains:
- <a view id present on the home screen>
Where each value comes from
package
adb shell dumpsys window | grep mCurrentFocus
playback_config_url
The app fetches a JSON config at startup that carries the live stream ids and playback tokens. Find it in a discover session:
./bin/drm proxy discover --install-ca --reinject
grep -oE 'https?://[^"]+(config|playback)[^"]*\.json' \
outputs/discover/*/appproxy_traffic.jsonl | sort -u
Fetch it and look at the keys:
curl -s '<playback_config_url>' | jq 'keys'
You will see entries along the lines of <region>StreamId and
liveLoggedOutToken<REGION>. Those names go in your channel entries, not in Go
— the module decodes the config generically, so a renamed upstream field is a
one-line edit here.
account_id
The Brightcove account id appears in the catalog request path and in the manifest URL:
grep -oE 'edge\.api\.brightcove\.com/playback/v1/accounts/[0-9]+' \
outputs/discover/*/appproxy_traffic.jsonl | sort -u
policy_key
A BCpkAD… string the app sends as Accept: application/json;pk=<policyKey> on
every catalog call. It is embedded in the APK and visible on the wire:
grep -oE 'pk=BCpkAD[A-Za-z0-9_-]+' outputs/discover/*/appproxy_traffic.jsonl \
| sort -u
Treat it as a secret: keep it in this gitignored file, or pass it per run.
./bin/drm catalog --app tg4 --channel chanone --tg4.policy-key 'BCpkAD...'
export TG4_POLICY_KEY='BCpkAD...'
channels.*.video_id vs stream_id_field
Two ways to name the Brightcove video for a channel:
video_id— pin it directly. Stable, and what you want once you know it.stream_id_field— read it fromplayback_config.jsonat run time. Survives the provider rotating ids.
Set both and video_id wins. Find a channel's id by matching the config field to
the manifest URL you captured — the id appears as the first path segment:
https://<cdn>/<videoId>/<region>/<accountId>/<jwt>/playlist-hls-dvr.m3u8
channels.*.token_field
The field in playback_config.json holding the long-lived JWT used as
livePlaybackToken on the catalog call. Each channel/region has its own. Without
the right one the catalog returns no playable source.
channels.*.live_card_index
Only used for phone capture. It is the channel's position in the app's Live page card list, counting from 0, top to bottom.
Open the Live page by hand and dump the tree:
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml
Content cards are the clickable rows below the title area. The module finds them by geometry and ignores chrome (Search, Profile, Cast, drawer). Confirm your indices by running a capture and checking the tap y-coordinate changes between channels:
[*] tap card #0 @ 540,732
[*] tap card #1 @ 540,1494
If the wanted index is off screen the module scrolls and re-counts, so a long list still works.
channels.*.phone_only
Set it for a channel that has no catalog entry. drm catalog then fails with a
message pointing at phone capture instead of a confusing "no video id" error.
ui.launch_resource_contains
This app's home screen exposes almost no useful content-desc — only chrome like
Search, Profile, Cast and the drawer button — so readiness is keyed off view
ids, which are stable and not localized.
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml | grep -oE 'resource-id="[^"]*"' \
| sed 's/.*\///' | sort -u
Pick a container that only exists once the home screen has rendered, e.g. the content rail's refresh layout or the drawer toolbar.
This matters: without a marker that actually matches, every capture burns the full launch timeout before auto-play recovers. It still works, just 30 s slower each time.
score.hosts / score.deny
hosts is the live CDN host that serves manifests. deny is for analytics — this
provider's metrics beacons carry .m3u8 in a query string and otherwise score as
manifests.
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
| sort | uniq -c | sort -rn | head -20
Verify
Catalog path first — it needs no phone:
./bin/drm modules
./bin/drm catalog --app tg4 --list
CHANNEL CATALOG ID LABEL
chanone 6383454185112 Main
kids 6383455473112 Kids
sport - Sport
provider flags: cula4Enabled=true liveEnabled=true ...
A - in CATALOG ID means neither video_id nor stream_id_field resolved.
provider flags are the booleans from playback_config.json, handy for spotting a
channel the provider has switched off.
Then resolve one for real:
./bin/drm catalog --app tg4 --channel chanone --keys --wvd data/device.wvd
Expect an HLS DVR manifest, a /lic/wv?token= license URL, a PSSH, and several
KID:KEY lines.
Finally, cross-check the phone path against it — independent routes agreeing on the PSSH and the full key set is strong evidence both are configured correctly:
./bin/drm catalog --app tg4 --channel chanone --keys --wvd data/device.wvd --json > a.json
./bin/drm capture --app tg4 --channel chanone --auto-play --wvd data/device.wvd
diff <(jq -r '.keys[]' a.json | sort) <(sort outputs/tg4/latest/key.txt)
Optional tuning
stream_name_prefix: tg4 # prefix for generated stream names
cards:
min_y: 300 # ignore nodes above this (title area / chrome)
min_h: 250
min_w: 600
desc_deny: ["Search", "Profile", "Cast", "Open", "Closed"]
text_deny: ["catch-up"]
ui:
live_label: live # text identifying the Live menu entry and title
live_title_resource: <view id of the Live page title>
drawer_desc: ["Open", "Closed"]
drawer_fallback_x: 68 # tapped when the drawer button cannot be found
drawer_fallback_y: 183
menu_max_x: 700 # bounds for a drawer menu entry
menu_min_y: 250
menu_max_y: 700
playback_desc_contains: ["video player"]
playback_resource_contains: [<player view id>]
timeouts:
launch_wait_ui: 30s
live_nav: 25s
card: 25s
playback: 60s
The player often starts without a dumpsys audio session, so playback is confirmed
by playback_resource_contains / playback_desc_contains and the wait is
soft-failing: capture continues even if neither signal appears.
Troubleshooting
| Symptom | Cause |
|---|---|
INVALID_POLICY_KEY (HTTP 401) |
wrong or rotated policy_key |
no playback token for channel "x" (field Y) |
token_field is not a key in playback_config.json |
no video id for channel "x" |
set video_id, or a stream_id_field that exists |
no HLS source in playback response |
the channel is off air, or the token is for a different region |
launch: ui node not found |
ui.launch_resource_contains does not match — re-dump the tree |
tap card #N picks the wrong channel |
card order changed; re-check live_card_index |
| Fewer keys than expected | a multi-KID stream needs every key; check key.txt, not just key |