# 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=` | | 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: proxy_bin: bin/proxy-android-arm64 ca_hash: "6c3578b4" account_id: "" policy_key: "BCpkAD..." playback_config_url: "https:////playback_config.json" score: hosts: - deny: - aliases: main: chanone plus1: plus channels: chanone: label: "Main" video_id: "" stream_id_field: token_field: live_card_index: 0 kids: label: "Kids" video_id: "" token_field: 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: - ``` ## 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 '' | jq 'keys' ``` You will see entries along the lines of `StreamId` and `liveLoggedOutToken`. **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=` 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:///////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: 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: [] 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` |