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.
288 lines
8.8 KiB
Markdown
288 lines
8.8 KiB
Markdown
# 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
|
|
|
|
```yaml
|
|
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`
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
./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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
./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 from `playback_config.json` at 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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```text
|
|
[*] 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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
|
|
| sort | uniq -c | sort -rn | head -20
|
|
```
|
|
|
|
## Verify
|
|
|
|
Catalog path first — it needs no phone:
|
|
|
|
```bash
|
|
./bin/drm modules
|
|
./bin/drm catalog --app tg4 --list
|
|
```
|
|
|
|
```text
|
|
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:
|
|
|
|
```bash
|
|
./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:
|
|
|
|
```bash
|
|
./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
|
|
|
|
```yaml
|
|
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` |
|