Null-DRM-Official/docs/providers/tg4.md
404errordeveloper 2fa8f2435f Initial commit: Null DRM Official
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.
2026-10-06 00:25:35 +02:00

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` |