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

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

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