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.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
288
docs/providers/tg4.md
Normal file
288
docs/providers/tg4.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# 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` |
|
||||
Loading…
Add table
Add a link
Reference in a new issue