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
40
docs/README.md
Normal file
40
docs/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Documentation
|
||||
|
||||
| Doc | Read it when |
|
||||
|---|---|
|
||||
| [quickstart.md](quickstart.md) | You just cloned the repo and want something working |
|
||||
| [architecture.md](architecture.md) | You want to know how the pieces fit and why |
|
||||
| [modules.md](modules.md) | You are adding support for a new streaming app |
|
||||
| [capture.md](capture.md) | You need to discover an app's URLs, IDs and UI selectors |
|
||||
| [streamd.md](streamd.md) | You are running the control plane + agent |
|
||||
| [providers/rte.md](providers/rte.md) | You are filling in `apps/modules/rte/module.yaml` |
|
||||
| [providers/tg4.md](providers/tg4.md) | You are filling in `apps/modules/tg4/module.yaml` |
|
||||
|
||||
## The one idea worth knowing first
|
||||
|
||||
The repo separates **logic** from **values**:
|
||||
|
||||
```text
|
||||
apps/modules/tg4/*.go tracked. How to drive the app. No secrets, ever.
|
||||
apps/modules/tg4/module.yaml gitignored. Account IDs, policy keys, video IDs, KIDs.
|
||||
```
|
||||
|
||||
Both module packages have a test asserting an empty config yields empty
|
||||
credentials, so the separation is checked rather than merely intended:
|
||||
|
||||
```bash
|
||||
go -C apps/modules test ./... -run NoHardcoded -v
|
||||
```
|
||||
|
||||
A fresh clone therefore builds and runs, but knows no channels until you supply
|
||||
your own values. Each `providers/*.md` doc explains where to get them.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go -C apps/cli build -o ../../bin/drm . # Windows: -o ../../bin/drm.exe
|
||||
./bin/drm proxy build # once, for phone capture
|
||||
```
|
||||
|
||||
There is no build script and no Makefile — `go build` is the build system.
|
||||
[quickstart.md](quickstart.md) lists every command.
|
||||
241
docs/architecture.md
Normal file
241
docs/architecture.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Architecture
|
||||
|
||||
## What the system does
|
||||
|
||||
Given an Android app that streams DRM-protected live TV, it recovers the three
|
||||
things a downloader needs — **manifest URL**, **Widevine content keys**, and any
|
||||
**auth headers** — and keeps them fresh.
|
||||
|
||||
Two independent ways to get them:
|
||||
|
||||
1. **Catalog** (`drm catalog`) — the app's playback catalog is public, so a plain
|
||||
HTTPS request plus a local CDM is enough. No phone.
|
||||
2. **Phone capture** (`drm capture`) — launch the real app under an on-device
|
||||
HTTPS MITM, drive its UI to the channel, and read the license exchange.
|
||||
|
||||
What it deliberately does not do: break DRM. The CDM used is a provisioned
|
||||
Widevine device you supply (`.wvd`); the system only automates the request flow a
|
||||
real client performs.
|
||||
|
||||
## One binary
|
||||
|
||||
```text
|
||||
┌──────────────────────── bin/drm ────────────────────────┐
|
||||
│ │
|
||||
modules ───┤ capture catalog agent serve proxy │
|
||||
linked in │ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`apps/cli` is the only `main`. It blank-imports `drmdecryption/modules/all`, which
|
||||
is what makes `--app <name>` resolve. Each app module registers itself:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
appreg.Register(Name, New) // --app rte
|
||||
appreg.RegisterFlags(bindFlags) // --rte.<field> overrides
|
||||
}
|
||||
```
|
||||
|
||||
Adding a provider means adding a package and one import line. There is no plugin
|
||||
loading, no scripting runtime, and nothing to deploy beside the binary.
|
||||
|
||||
## Logic vs values
|
||||
|
||||
This is the central constraint:
|
||||
|
||||
| `apps/modules/<name>/*.go` — **tracked** | `apps/modules/<name>/module.yaml` — **gitignored** (bbc published) |
|
||||
|---|---|
|
||||
| launch + auto-play sequence | android package id |
|
||||
| navigation idioms (chip→Play, drawer→Live) | license URL, origin hostnames |
|
||||
| manifest rewrite shape | channel KIDs, account id, policy key |
|
||||
| catalog request flow | video ids, playback config URL |
|
||||
| which capture fields are required | UI selector regexes, labels, aliases |
|
||||
|
||||
Module Go source contains no account id, policy key, video id, license URL, origin
|
||||
host or KID. Values arrive by **flag → environment → module.yaml → Go default**,
|
||||
and only mechanical things (timeouts, DASH timescales, card geometry) have
|
||||
defaults in code.
|
||||
|
||||
`.gitignore` enforces the split:
|
||||
|
||||
```gitignore
|
||||
apps/modules/**
|
||||
!apps/modules/README.md
|
||||
!apps/modules/go.mod
|
||||
!apps/modules/go.sum
|
||||
!apps/modules/*/
|
||||
!apps/modules/*/*.go
|
||||
!apps/modules/*/**/*.go
|
||||
```
|
||||
|
||||
The directory re-include is required — git will not descend into an excluded
|
||||
directory to find negated files.
|
||||
|
||||
## Go modules
|
||||
|
||||
Six modules, wired with `replace`:
|
||||
|
||||
```text
|
||||
apps/pkg drmdecryption shared, provider-neutral
|
||||
apps/modules drmdecryption/modules app modules -> pkg
|
||||
apps/capture drmdecryption/apps/capture capture command -> pkg
|
||||
apps/agent drmdecryption/apps/agent agent -> pkg
|
||||
apps/streamd drmdecryption/apps/streamd control plane -> pkg
|
||||
apps/proxy drmdecryption/apps/proxy MITM host CLI -> pkg
|
||||
apps/cli drmdecryption/apps/cli the binary -> all of the above
|
||||
|
||||
apps/proxy/device appproxy standalone, cross-compiled for the phone
|
||||
```
|
||||
|
||||
Command bodies live in non-`internal` packages (`capturecmd`, `agentcmd`,
|
||||
`servecmd`, `proxyctlcmd`) because `internal/` is not importable across module
|
||||
boundaries and `apps/cli` has to call them.
|
||||
|
||||
## Plugin contract
|
||||
|
||||
`apps/pkg/app` defines what a module must provide and what it may add.
|
||||
|
||||
Required (`app.App`):
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `Name`, `Package`, `LicenseURL` | identity |
|
||||
| `Launch(c)` | cold-start the app, wait for its home UI |
|
||||
| `AutoPlay(c, channel)` | navigate to a channel and confirm playback |
|
||||
| `CaptureHints()` | which capture fields are required, plus URL scoring |
|
||||
| `KeyMode()` | `"modulardrm"` or `"raw"` — never sniffed from the license URL |
|
||||
| `MPDRewriter()` | manifest rewrite, or `mpd.Passthrough{}` |
|
||||
| `Channels`, `HasChannel` | channel list and lookup |
|
||||
| `ProxyBin`, `CAHash` | on-device MITM binary and CA hash |
|
||||
|
||||
Optional, detected by type assertion:
|
||||
|
||||
| Interface | Gives you |
|
||||
|---|---|
|
||||
| `app.Catalog` | `drm catalog --app <name>` works |
|
||||
| `app.CatalogLister` | a richer `catalog --list` with catalog ids and provider flags |
|
||||
| `app.StreamDefaults` | streamd downloader settings (selectors, rewriter, buffering) |
|
||||
| `mpd.Rewriter` + `mpd.Register` | a named manifest rewrite streamd can resolve |
|
||||
|
||||
## Shared libraries (`apps/pkg`)
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `app` | plugin interface + registry + optional capability interfaces |
|
||||
| `adb` | ADB client: shell, push/pull, UI dump, tap, swipe, playback probe |
|
||||
| `uiflow` | UI primitives modules drive playback with — every threshold a parameter |
|
||||
| `capture` | parse on-device capture JSON; score candidate manifest URLs |
|
||||
| `phonecap` | one capture job end to end |
|
||||
| `proxy` | push/start/stop the on-device MITM, CA install/reinject, pull captures |
|
||||
| `wvkey` | shells out to `apps/wvkey/wvkey.py` |
|
||||
| `brightcove` | Brightcove Playback API + HLS Widevine extraction — a *platform*, not a provider |
|
||||
| `mpd` | rewriter interface + registry, synthetic live manifest builder, local server |
|
||||
| `modcfg` | load a module's `module.yaml`, apply env overrides, resolve paths |
|
||||
| `session` | write `outputs/<app>/<stamp>/` |
|
||||
| `repo` | locate the repo root |
|
||||
|
||||
Nothing here names a streaming provider.
|
||||
|
||||
## Phone capture pipeline
|
||||
|
||||
```text
|
||||
drm capture --app X --channel Y --auto-play
|
||||
│
|
||||
├─ push + start apps/proxy/device (appproxy) on the phone
|
||||
├─ set the phone's global http_proxy to 127.0.0.1:8080
|
||||
├─ Magisk-mount the MITM CA into the system trust store
|
||||
├─ module.Launch() : force-stop → monkey launch → wait for a home marker
|
||||
├─ module.AutoPlay() : app-specific navigation → wait for playback
|
||||
├─ capture.Wait() : poll the device capture JSON + mirrored proxy log
|
||||
│ until the module's required fields appear
|
||||
├─ wvkey.py : PSSH + license → KID:KEY (mode from module.KeyMode())
|
||||
└─ session.Write() : outputs/<app>/<stamp>/ + latest/
|
||||
```
|
||||
|
||||
The MITM runs **on the device**, so it cannot read a module's values file. A
|
||||
module's manifest-scoring hosts are passed to it as flags
|
||||
(`capture.ScoreCfg.ProxyArgs()` → `-score-host`, `-score-deny`).
|
||||
|
||||
### Why URL scoring exists
|
||||
|
||||
A MITM sees EPG feeds, analytics beacons and config blobs alongside the real
|
||||
manifest. `capture.ScoreCfg` ranks candidates: format-level signals (`.mpd`,
|
||||
`.m3u8`, `playlist-hls`, `/manifest`, `.isml`) are generic and live in `apps/pkg`;
|
||||
provider hosts and noise needles come from `module.yaml`.
|
||||
|
||||
## Manifest rewriting
|
||||
|
||||
Some providers serve an ad-stitched manifest whose session expires (HTTP 410)
|
||||
while the underlying origin keeps running. `apps/pkg/mpd` can publish a
|
||||
**synthetic** manifest on the origin encoder's own segment grid so a downloader
|
||||
keeps fetching real segments:
|
||||
|
||||
```text
|
||||
mpd.LearnGrid fetch the origin Smooth manifest once; derive segment
|
||||
duration and phase offset per media type
|
||||
mpd.BuildSynthetic emit a dynamic MPD on that grid, with a sliding window
|
||||
mpd.StartLocal serve it on 127.0.0.1 and re-run the rewrite on every GET
|
||||
mpd.Register/Lookup resolve a rewriter by name, so streamd needs no provider import
|
||||
```
|
||||
|
||||
`SyntheticConfig` holds every value that could differ — timescales, stream names,
|
||||
bandwidths, codecs, resolution, segment templates, live-edge offset, window size.
|
||||
The RTE module supplies origins and KIDs; when the upstream manifest is gone, a
|
||||
`mpd.KIDResolver` maps the KID from a previous capture back to its origin.
|
||||
|
||||
## Control plane (`drm serve`)
|
||||
|
||||
| Piece | Path |
|
||||
|---|---|
|
||||
| Command | `apps/streamd/servecmd` |
|
||||
| HTTP API | `apps/streamd/internal/api` |
|
||||
| SQLite | `apps/streamd/internal/db` |
|
||||
| Dashboard | `apps/streamd/internal/ui` |
|
||||
| Media supervisor | `apps/streamd/internal/supervisor` |
|
||||
|
||||
Port 8083 by default. Mutating routes need `Authorization: Bearer <token>`.
|
||||
|
||||
Streamd stores no provider knowledge. `GET /api/apps` reports the compiled-in
|
||||
modules, and the supervisor resolves per-stream downloader settings at start time:
|
||||
|
||||
```text
|
||||
settingsFor(stream):
|
||||
stored row -> the stream's app module (app.StreamDefaults) -> neutral defaults
|
||||
```
|
||||
|
||||
The rewriter is a registry lookup on the stream's `rewriter` column, and HLS is
|
||||
detected from the manifest shape — not from an app name or a stream name.
|
||||
|
||||
## Agent (`drm agent run`)
|
||||
|
||||
```text
|
||||
poll streamd ──► durable SQLite queue ──► claim a free ADB device
|
||||
│
|
||||
POST credentials ◄── phone capture (phonecap)
|
||||
```
|
||||
|
||||
Per-stream claims expire via a websocket heartbeat, so a dead agent releases its
|
||||
phones. The agent never installs apps and never runs a downloader. It drives
|
||||
whichever module a stream names; it contains no provider logic.
|
||||
|
||||
## Where things land
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `data/` | durable secrets: `.wvd`, CA — gitignored |
|
||||
| `outputs/<app>/<stamp>/` | capture + catalog sessions, with `latest/` as a copy |
|
||||
| `outputs/discover/<stamp>/` | MITM discover dumps |
|
||||
| `.cache/streamd/` | streamd SQLite, work dirs, HLS output |
|
||||
| `.cache/ui-<app>/` | UI dumps used by the automation |
|
||||
|
||||
## Honest status
|
||||
|
||||
| Area | State |
|
||||
|---|---|
|
||||
| Capture (catalog + phone), both modules | working, verified against live services |
|
||||
| Compiled-in modules, one binary | done |
|
||||
| streamd API + SQLite + dashboard + claims | done |
|
||||
| Synthetic manifest rewrite | done, verified against a live origin |
|
||||
| Agent queue + phone refresh | done |
|
||||
| Real downloader/ffmpeg workers in streamd | partial — the supervisor shells out to external tools and is the least exercised path |
|
||||
262
docs/capture.md
Normal file
262
docs/capture.md
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
# Using the capture binary
|
||||
|
||||
`drm capture` runs an HTTPS MITM **on the phone**, drives the app, and records the
|
||||
DRM exchange. It is both the production capture path and the tool you use to
|
||||
discover everything a new module needs.
|
||||
|
||||
## Device setup (once)
|
||||
|
||||
```bash
|
||||
adb devices # phone listed and authorised
|
||||
./bin/drm proxy build # cross-compile the on-device MITM (linux/arm64)
|
||||
./bin/drm proxy install-ca --reinject
|
||||
```
|
||||
|
||||
The phone must be rooted (Magisk). `install-ca` pushes the MITM CA and bind-mounts
|
||||
it over the system trust store, including into already-running zygote namespaces —
|
||||
a user-store CA is not enough, modern apps ignore it.
|
||||
|
||||
Verify the mount:
|
||||
|
||||
```bash
|
||||
adb shell 'ls /system/etc/security/cacerts/ | grep 6c3578b4'
|
||||
adb shell 'su -c "ls /apex/com.android.conscrypt/cacerts/6c3578b4.0"'
|
||||
```
|
||||
|
||||
If an app still reports `tls: unknown certificate`, re-run the reinject and
|
||||
force-stop the app so it inherits the mount:
|
||||
|
||||
```bash
|
||||
./bin/drm proxy reinject-ca
|
||||
adb shell am force-stop <package>
|
||||
```
|
||||
|
||||
Keep the screen on for the whole session — UI automation cannot tap a dark screen:
|
||||
|
||||
```bash
|
||||
adb shell svc power stayon true
|
||||
adb shell settings put system screen_off_timeout 1800000
|
||||
```
|
||||
|
||||
Restore the timeout afterwards (default is usually 60000).
|
||||
|
||||
## Three modes
|
||||
|
||||
### Passive — no module needed
|
||||
|
||||
```bash
|
||||
./bin/drm capture --wait 300
|
||||
```
|
||||
|
||||
Starts the MITM and nothing else. You open any app and play something; it records
|
||||
whatever license, PSSH and manifest traffic appears and writes
|
||||
`outputs/capture/<stamp>/`. This is the first thing to run against an unknown app.
|
||||
|
||||
### Launch only
|
||||
|
||||
```bash
|
||||
./bin/drm capture --app myapp --wvd data/device.wvd
|
||||
```
|
||||
|
||||
Launches the app, then waits while you navigate by hand. Useful while you are still
|
||||
working out the UI sequence.
|
||||
|
||||
### Full automation
|
||||
|
||||
```bash
|
||||
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
|
||||
```
|
||||
|
||||
Runs the module's `Launch` and `AutoPlay`, then waits for the required fields.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--app` | _(empty)_ | module to drive; empty means passive |
|
||||
| `--channel` | — | channel id, required by `--auto-play` |
|
||||
| `--auto-play` | false | run the module's navigation |
|
||||
| `--wait` | 180 | seconds to wait for the capture to complete |
|
||||
| `--wvd` | — | Widevine device file; required to fetch keys |
|
||||
| `--python` | `.venv` | interpreter for `wvkey.py` |
|
||||
| `--key-mode` | from the module | force `modulardrm` or `raw` |
|
||||
| `--user-agent` | — | UA for the license request |
|
||||
| `--serial` | sole device | target a specific phone |
|
||||
| `--close` | true | force-stop the app when done |
|
||||
| `--<app>.<field>` | — | override any of that module's values |
|
||||
|
||||
## What a run looks like
|
||||
|
||||
```text
|
||||
[*] Stopping any old appproxy...
|
||||
[*] Pushing appproxy...
|
||||
[+] appproxy pid=7604; capture -> /data/local/tmp/appproxy_cap.json
|
||||
[*] Setting HTTP proxy -> 127.0.0.1:8080 (was "null")
|
||||
[*] Re-injecting system CA (Magisk)...
|
||||
[+] System CA present in conscrypt
|
||||
[*] Launching rte (air.RTE.OSMF.Minimal)…
|
||||
[*] Auto-play RTE 2…
|
||||
[*] tap_ui "Live"/"Live tab, 2 out of 5" @ 324,2253
|
||||
[*] tap chip @ 356,297
|
||||
[*] tap Play @ 540,720
|
||||
[+] air.RTE.OSMF.Minimal is PLAYING
|
||||
[*] Waiting for license + PSSH + manifest…
|
||||
[+] Capture:
|
||||
pid: RKRPCEib8z9V
|
||||
mpd: https://dai.google.com/linear/dash/pa/event/.../stream/...
|
||||
license: https://widevine.entitlement.eu.theplatform.com/wv/web/ModularDrm?...
|
||||
pssh: AAAASnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAACoiIGRmMTYzMzgyMWRk…
|
||||
[+] key: 5490781a77572107ec251237e94e3b53:cee5fc45b70040ffea2b2576fb5abda5
|
||||
[+] session: outputs/rte/20261005-220252/session.json
|
||||
[*] Closing air.RTE.OSMF.Minimal…
|
||||
[*] HTTP proxy cleared
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
```text
|
||||
outputs/<app>/<stamp>/
|
||||
session.json everything, machine-readable
|
||||
mpd.txt manifest URL
|
||||
key.txt KID:KEY (one per line for multi-key streams)
|
||||
pssh.txt Widevine init data
|
||||
auth.txt Authorization value, when the app uses one
|
||||
pid.txt provider id, when the app uses one
|
||||
outputs/<app>/latest/ a copy of the most recent run
|
||||
```
|
||||
|
||||
Live log while a capture runs: `.cache/appproxy-<serial>.log`.
|
||||
|
||||
---
|
||||
|
||||
## Discovering a new app
|
||||
|
||||
### 1. Record everything
|
||||
|
||||
```bash
|
||||
./bin/drm proxy discover --install-ca --reinject
|
||||
# play the app on the phone; Ctrl+C when done
|
||||
```
|
||||
|
||||
Writes `outputs/discover/<stamp>/`:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `appproxy_traffic.jsonl` | every HTTP(S) request/response, one JSON object per line |
|
||||
| `appproxy_cap.json` | the structured fields the proxy recognised |
|
||||
| `appproxy.log` | tagged lines: `[MPD]`, `[LIC]`, `[PSSH]`, `[MAN]` |
|
||||
|
||||
### 2. Mine it
|
||||
|
||||
```bash
|
||||
# the android package id
|
||||
adb shell dumpsys window | grep mCurrentFocus
|
||||
|
||||
# license endpoint
|
||||
grep -iE 'license|licence|widevine|/lic/' outputs/discover/*/appproxy.log
|
||||
|
||||
# manifest candidates
|
||||
grep -oE 'https?://[^"]+\.(m3u8|mpd)' outputs/discover/*/appproxy_traffic.jsonl \
|
||||
| sort -u
|
||||
|
||||
# the hosts involved, by frequency
|
||||
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
|
||||
| sort | uniq -c | sort -rn | head -20
|
||||
|
||||
# config blobs that often carry ids and tokens
|
||||
grep -oE 'https?://[^"]+(config|playback)[^"]*\.json' \
|
||||
outputs/discover/*/appproxy_traffic.jsonl | sort -u
|
||||
```
|
||||
|
||||
What to pull out, and where it goes in `module.yaml`:
|
||||
|
||||
| Found | Key |
|
||||
|---|---|
|
||||
| package id | `package` |
|
||||
| license endpoint | `license_url` |
|
||||
| CDN host serving the manifest | `score.hosts` |
|
||||
| analytics / EPG hosts polluting the results | `score.deny` |
|
||||
| account ids, policy keys, video ids | provider-specific keys |
|
||||
|
||||
### 3. Work out the UI
|
||||
|
||||
Dump the tree at each step and read off stable selectors:
|
||||
|
||||
```bash
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb shell cat /sdcard/ui.xml > ui.xml
|
||||
```
|
||||
|
||||
Extract the useful attributes:
|
||||
|
||||
```bash
|
||||
grep -oE 'resource-id="[^"]*"' ui.xml | sort -u
|
||||
grep -oE 'content-desc="[^"]*"' ui.xml | sort -u
|
||||
```
|
||||
|
||||
Prefer `resource-id` over `content-desc` or `text` — ids survive translation and
|
||||
copy changes. Check a candidate marker really exists before you rely on it; a
|
||||
launch marker that never matches costs the full timeout on every capture and is
|
||||
easy to miss because the pipeline recovers anyway.
|
||||
|
||||
Test taps by hand before writing Go:
|
||||
|
||||
```bash
|
||||
adb shell input tap 540 732
|
||||
adb shell input swipe 540 1700 540 700 350
|
||||
```
|
||||
|
||||
### 4. Determine the key mode
|
||||
|
||||
| The app sends | Use |
|
||||
|---|---|
|
||||
| a JSON wrapper around the challenge, with an Authorization header | `modulardrm` |
|
||||
| a raw binary challenge, `Content-Type: application/octet-stream` | `raw` |
|
||||
|
||||
Check the license request body in `appproxy_traffic.jsonl`. Then set `KeyMode()`
|
||||
in the module — never sniff it from the URL at runtime.
|
||||
|
||||
### 5. Decide which fields are required
|
||||
|
||||
Look at what `appproxy_cap.json` actually contained after a successful play:
|
||||
|
||||
- DASH + JSON license → usually `auth`, `pid`, `pssh`, `mpd`
|
||||
- HLS + binary license → usually `license_url`, `mpd` (the PSSH comes from the
|
||||
playlist afterwards)
|
||||
|
||||
Pass exactly those to `a.Hints(...)`. Asking for a field the app never emits makes
|
||||
every capture time out.
|
||||
|
||||
### 6. Verify
|
||||
|
||||
Where an app has a public catalog, capture the same channel both ways and compare —
|
||||
independent paths agreeing on the PSSH and key set is strong evidence both are
|
||||
right:
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app myapp --channel main --keys --wvd data/device.wvd --json > a.json
|
||||
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
|
||||
diff <(jq -r '.keys[]' a.json | sort) <(sort outputs/myapp/latest/key.txt)
|
||||
```
|
||||
|
||||
Also sanity-check that the KID you captured matches whatever you recorded in
|
||||
`module.yaml` for that channel.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| `tls: unknown certificate` in the app | CA not mounted into conscrypt; reinject, then force-stop the app |
|
||||
| `timed out waiting for capture ([auth pid pssh mpd])` | playback never started, or `Hints` asks for a field this app does not send |
|
||||
| `launch: ui node not found within Ns` | launch marker does not match — dump the tree and pick a resource id |
|
||||
| `launch: exit status 1` | monkey raced a force-stop; usually transient, retry |
|
||||
| `auto-play failed: ... not found` | UI changed, or the phone is on a different screen than expected |
|
||||
| manifest field holds an EPG or config URL | add that host to `score.deny` |
|
||||
| `CA inject skipped/failed` | transient after repeated runs; re-run `proxy reinject-ca` |
|
||||
| Screen asleep mid-run | `adb shell svc power stayon true` |
|
||||
|
||||
Clear the phone's proxy if a run is interrupted:
|
||||
|
||||
```bash
|
||||
./bin/drm proxy clear-proxy
|
||||
```
|
||||
379
docs/modules.md
Normal file
379
docs/modules.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# Writing an app module
|
||||
|
||||
A module teaches the binary how to drive one streaming app. It is a Go package, it
|
||||
is compiled in, and it contains no secrets.
|
||||
|
||||
Work through [capture.md](capture.md) first — you need the app's package id,
|
||||
license URL, manifest host and UI selectors before you can write any of this.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```text
|
||||
apps/modules/myapp/
|
||||
config.go Config struct + defaults + flag binding
|
||||
myapp.go init(), the App type, Launch, AutoPlay, capability methods
|
||||
steps.go navigation idioms specific to this app
|
||||
catalog.go optional: resolve channels without a phone
|
||||
mpd.go optional: manifest rewriting
|
||||
module.yaml GITIGNORED - your values
|
||||
```
|
||||
|
||||
Everything is registered from `init()`, and one line in `apps/modules/all/all.go`
|
||||
links it into the binary.
|
||||
|
||||
## 1. Config
|
||||
|
||||
Embed `provider.Common` for the fields every module has, then add your own.
|
||||
|
||||
```go
|
||||
package myapp
|
||||
|
||||
import (
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
)
|
||||
|
||||
const Name = "myapp"
|
||||
|
||||
type Channel struct {
|
||||
Label string `yaml:"label"`
|
||||
LiveCardIndex int `yaml:"live_card_index"`
|
||||
PlayDesc []string `yaml:"play_desc"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
provider.Common `yaml:",inline"`
|
||||
|
||||
// Provider values. Never give these defaults in code.
|
||||
APIBase string `yaml:"api_base"`
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
|
||||
Timeouts Timeouts `yaml:"timeouts"`
|
||||
UI UI `yaml:"ui"`
|
||||
}
|
||||
|
||||
type Timeouts struct {
|
||||
// provider.Duration accepts "25s" and bare seconds; time.Duration alone
|
||||
// would force raw nanoseconds in YAML.
|
||||
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||
Playback provider.Duration `yaml:"playback"`
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
LaunchResourceContains []string `yaml:"launch_resource_contains"`
|
||||
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
t := &c.Timeouts
|
||||
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(25 * time.Second))
|
||||
t.Playback = provider.Duration(t.Playback.D(45 * time.Second))
|
||||
return c
|
||||
}
|
||||
```
|
||||
|
||||
`provider.Common` gives you `package`, `license_url`, `proxy_bin`, `ca_hash`,
|
||||
`aliases` and `score`.
|
||||
|
||||
### Loading and overrides
|
||||
|
||||
```go
|
||||
var flags struct {
|
||||
packageName string
|
||||
apiBase string
|
||||
}
|
||||
|
||||
func loadConfig() (Config, modcfg.Result, error) {
|
||||
var cfg Config
|
||||
res, err := modcfg.Load(Name, &cfg) // reads apps/modules/myapp/module.yaml
|
||||
if err != nil {
|
||||
return cfg, res, err
|
||||
}
|
||||
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||
cfg.APIBase = modcfg.Override(Name, "api_base", flags.apiBase, cfg.APIBase)
|
||||
return cfg.withDefaults(), res, nil
|
||||
}
|
||||
```
|
||||
|
||||
`modcfg.Override` applies **flag → environment (`MYAPP_API_BASE`) → file**. A
|
||||
missing `module.yaml` is not an error: the module must still construct so
|
||||
`drm modules` can report it as unconfigured.
|
||||
|
||||
## 2. The App type
|
||||
|
||||
```go
|
||||
func init() {
|
||||
appreg.Register(Name, New)
|
||||
appreg.RegisterFlags(bindFlags)
|
||||
}
|
||||
|
||||
func bindFlags(fs *flag.FlagSet) {
|
||||
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" package id")
|
||||
fs.StringVar(&flags.apiBase, Name+".api-base", "", "override "+Name+" API base")
|
||||
}
|
||||
|
||||
type App struct {
|
||||
*provider.Base
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() (appreg.App, error) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := map[string]string{}
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Flag binding is a separate hook from construction on purpose: flags must be bound
|
||||
to the config holder *before* any app is built, or overrides would be read too
|
||||
late.
|
||||
|
||||
`provider.Base` supplies `Name`, `Package`, `LicenseURL`, `ProxyBin`, `CAHash`,
|
||||
`HasChannel`, `Label`, `ChannelIDs`, `CacheDir`, alias-aware `Resolve`, and
|
||||
`Hints(require…)` preloaded with your `score:` config.
|
||||
|
||||
### Required methods
|
||||
|
||||
```go
|
||||
func (a *App) KeyMode() string { return "raw" } // or "modulardrm"
|
||||
|
||||
func (a *App) CaptureHints() capture.Hints {
|
||||
return a.Hints("license_url", "mpd") // fields the MITM must yield
|
||||
}
|
||||
|
||||
func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||
|
||||
func (a *App) Channels() []appreg.Channel {
|
||||
out := make([]appreg.Channel, 0, len(a.cfg.Channels))
|
||||
for _, id := range a.ChannelIDs() {
|
||||
out = append(out, appreg.Channel{ID: id, Label: a.Label(id)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
`Hints(...)` names the JSON keys that must be present before a capture counts as
|
||||
complete. Common sets:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `mpd` | manifest URL (DASH or HLS — the field name is historical) |
|
||||
| `license_url` | license endpoint seen on the wire |
|
||||
| `pssh` | Widevine init data |
|
||||
| `auth` | Authorization header value |
|
||||
| `pid` | provider-specific id (e.g. a release/program id) |
|
||||
|
||||
Ask only for what the app actually produces. Requiring `pssh` from an HLS app that
|
||||
puts it in the playlist will hang the capture.
|
||||
|
||||
## 3. Launch and AutoPlay
|
||||
|
||||
```go
|
||||
func (a *App) Launch(c *adb.Client) error {
|
||||
c.EnsureAwake()
|
||||
c.ForceStop(a.Package())
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if err := uiflow.MonkeyLaunch(c, a.Package()); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.LaunchSettle))
|
||||
c.DismissShadeIfFocused()
|
||||
return uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{
|
||||
ResourceContains: a.cfg.UI.LaunchResourceContains,
|
||||
DescContains: a.cfg.UI.LaunchDescContains,
|
||||
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI))
|
||||
}
|
||||
|
||||
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||
id, err := a.Base.Resolve(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch := a.cfg.Channels[id]
|
||||
|
||||
if err := a.openLivePage(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := uiflow.TapCard(c, a.CacheDir(), ch.LiveCardIndex,
|
||||
time.Duration(a.cfg.Timeouts.Card), a.cfg.Cards); err != nil {
|
||||
return err
|
||||
}
|
||||
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||
SoftFail: true,
|
||||
OrResourceContains: a.cfg.UI.PlaybackResourceContains,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
A `Launch` error is logged and tolerated by the capture pipeline, so pick a launch
|
||||
marker that genuinely exists — otherwise every run silently burns the full timeout
|
||||
before `AutoPlay` recovers. **Prefer resource ids over descriptions**: ids are
|
||||
stable and not localized. Dump the real tree to find them:
|
||||
|
||||
```bash
|
||||
adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml
|
||||
```
|
||||
|
||||
### uiflow
|
||||
|
||||
| Function | Use |
|
||||
|---|---|
|
||||
| `WaitUI(c, cache, Match, timeout)` | block until a node exists |
|
||||
| `TapUI(c, cache, Match, timeout)` | wait, then tap its centre |
|
||||
| `Find(c, cache, Match, timeout)` | get the node for custom handling |
|
||||
| `WaitPlayback(c, cache, pkg, PlaybackOpts)` | audio session, or a UI fallback signal |
|
||||
| `Cards(nodes, CardOpts)` | content rows in a dump, top to bottom |
|
||||
| `WaitCard` / `TapCard` | the nth content row, scrolling if needed |
|
||||
| `MonkeyLaunch(c, pkg)` | launcher-intent start |
|
||||
| `CompileRes([]string)` | case-insensitive regexes |
|
||||
|
||||
`Match` combines `TextRE`, `DescRE`, `TextContains`, `DescContains`,
|
||||
`ResourceContains`, plus `Pick` (`largest`/`smallest`/`first`) and `MinArea`.
|
||||
|
||||
`WaitPlayback` normally confirms via the audio session. Players that start without
|
||||
one need `SoftFail: true` plus a UI signal (`OrDescContains`,
|
||||
`OrResourceContains`) so capture proceeds anyway.
|
||||
|
||||
`CardOpts` carries geometry (`MinY`, `MinH`, `MinW`) and denylists (`DescDeny`,
|
||||
`TextDeny`) so chrome is not mistaken for content. Defaults suit a 1080-wide
|
||||
phone; put real values in `module.yaml` if your app differs.
|
||||
|
||||
## 4. Optional: catalog
|
||||
|
||||
Implement `app.Catalog` and `drm catalog --app myapp` starts working.
|
||||
|
||||
```go
|
||||
func (a *App) Resolve(channel string) (session.Stream, error) {
|
||||
id, err := a.Base.Resolve(channel)
|
||||
// ... hit the provider API, pick a manifest + license URL ...
|
||||
return session.Stream{
|
||||
Name: Name + "-" + id, App: Name, Channel: id,
|
||||
MPD: manifestURL, LicenseURL: licenseURL,
|
||||
HeadersJSON: "{}", Rewriter: "none", ManifestType: "hls",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) EnrichWithKeys(info *session.Stream, python, wvd string) error {
|
||||
body, err := brightcove.Get(info.MPD)
|
||||
// ... extract PSSH, then wvkey.FetchRawAll ...
|
||||
}
|
||||
```
|
||||
|
||||
For a nicer `--list`, also implement `app.CatalogLister` returning
|
||||
`[]appreg.CatalogRow`.
|
||||
|
||||
Decode remote config generically. Do not model a provider's JSON field names as Go
|
||||
struct fields — read into `map[string]any` and let `module.yaml` name the fields:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
main:
|
||||
token_field: liveLoggedOutTokenIOI
|
||||
stream_id_field: irelandStreamId
|
||||
```
|
||||
|
||||
A renamed upstream field then needs an edit to your values file, not a rebuild.
|
||||
|
||||
## 5. Optional: manifest rewriting
|
||||
|
||||
Only needed when the captured manifest expires faster than the stream.
|
||||
|
||||
```go
|
||||
func init() {
|
||||
mpd.Register(Name, func() mpd.Rewriter { return NewRewriter() })
|
||||
}
|
||||
|
||||
func (r *Rewriter) Name() string { return Name }
|
||||
|
||||
func (r *Rewriter) Rewrite(upstreamXML []byte, kidHint string) ([]byte, error) {
|
||||
// identify the origin channel from the manifest or the KID, then:
|
||||
return mpd.BuildSynthetic(r.cfg.Synthetic, channel, originBase, kid)
|
||||
}
|
||||
|
||||
// Optional: recover the origin when upstream is gone and only the KID remains.
|
||||
func (r *Rewriter) ResolveKID(kidHex string) (channel, originBase string, ok bool)
|
||||
func (r *Rewriter) Prime(channel, originBase string)
|
||||
```
|
||||
|
||||
Register a **factory**, not an instance — a rewriter may hold per-stream state.
|
||||
|
||||
## 6. Optional: streamd settings
|
||||
|
||||
```go
|
||||
func (a *App) VideoSelect() string { return "for=best" }
|
||||
func (a *App) AudioSelect() string { return "lang=en:for=best" }
|
||||
func (a *App) RewriterName() string { return Name } // or "none"
|
||||
func (a *App) LiveWaitSeconds() int { return 6 }
|
||||
func (a *App) TSReadyBytes() int64 { return 2 << 20 }
|
||||
```
|
||||
|
||||
Without this, streamd uses neutral defaults. This is what replaced guessing from
|
||||
app and stream names.
|
||||
|
||||
## 7. Link it in
|
||||
|
||||
```go
|
||||
// apps/modules/all/all.go
|
||||
import (
|
||||
_ "drmdecryption/modules/myapp"
|
||||
_ "drmdecryption/modules/rte"
|
||||
_ "drmdecryption/modules/tg4"
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
go -C apps/cli build -o ../../bin/drm .
|
||||
./bin/drm modules
|
||||
```
|
||||
|
||||
## 8. Test it
|
||||
|
||||
Assert the interfaces at compile time and prove no secrets leaked:
|
||||
|
||||
```go
|
||||
var (
|
||||
_ appreg.App = (*App)(nil)
|
||||
_ appreg.Catalog = (*App)(nil)
|
||||
_ appreg.StreamDefaults = (*App)(nil)
|
||||
)
|
||||
|
||||
func TestNoHardcodedCredentials(t *testing.T) {
|
||||
cfg := Config{}.withDefaults()
|
||||
if cfg.Package != "" || cfg.APIBase != "" {
|
||||
t.Fatal("provider values must come from module.yaml")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`apps/modules/all/all_test.go` already checks every registered module constructs
|
||||
with no values file present, reports a valid key mode, and — if it declares a
|
||||
rewriter name — actually registered one.
|
||||
|
||||
For anything needing the live service or your local values, use a build tag so the
|
||||
normal run stays hermetic:
|
||||
|
||||
```go
|
||||
//go:build live
|
||||
```
|
||||
|
||||
```bash
|
||||
go -C apps/modules test -tags live ./myapp/ -v
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] No account id, key, token, video id, hostname or KID in any `.go` file
|
||||
- [ ] `module.yaml` is gitignored (`git check-ignore -v apps/modules/myapp/module.yaml`)
|
||||
- [ ] `New()` succeeds with no `module.yaml`
|
||||
- [ ] Launch marker actually matches — watch for a silent full-timeout wait
|
||||
- [ ] `CaptureHints` asks only for fields the app really produces
|
||||
- [ ] `KeyMode` is correct (`raw` for binary challenges, `modulardrm` for JSON)
|
||||
- [ ] Added to `all/all.go`
|
||||
- [ ] `drm modules` lists it with the right channel count
|
||||
58
docs/providers/bbc.md
Normal file
58
docs/providers/bbc.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# BBC module — filling in `module.yaml`
|
||||
|
||||
BBC iPlayer (Android). Streams from the `mobile-phone-main` mediaset are **clear**
|
||||
(CDN signed URLs); capture only needs the **MPD/HLS master**. Transparent MITM keeps
|
||||
NordVPN UK on the phone.
|
||||
|
||||
`apps/modules/bbc/module.yaml` is published in the repo (package + channel map only;
|
||||
no secrets). Skeleton:
|
||||
|
||||
```yaml
|
||||
package: bbc.iplayer.android
|
||||
proxy_bin: bin/proxy-android-arm64
|
||||
ca_hash: "6c3578b4"
|
||||
|
||||
score:
|
||||
hosts:
|
||||
- open.live.bbc.co.uk
|
||||
- vs-cmaf-push-uk
|
||||
- vod-dash-uk
|
||||
- akamaized.net
|
||||
- bbci.co.uk
|
||||
- bidi.net.uk
|
||||
deny:
|
||||
- telemetry.api.bbci
|
||||
- bag.api.bbc
|
||||
- thumbnail
|
||||
|
||||
aliases:
|
||||
one: bbcone
|
||||
bbc1: bbcone
|
||||
|
||||
channels:
|
||||
bbcone:
|
||||
label: BBC One
|
||||
vpid: bbc_one_london
|
||||
bbctwo:
|
||||
label: BBC Two
|
||||
vpid: bbc_two_england
|
||||
```
|
||||
|
||||
## Capture
|
||||
|
||||
```bash
|
||||
# Leave NordVPN UK ON. Magisk CA should already be mounted (skip --reinject).
|
||||
./bin/drm capture --app bbc --channel bbcone --wait 120
|
||||
# Play BBC One on the phone when prompted.
|
||||
```
|
||||
|
||||
Console prints the MPD in a banner; `outputs/bbc/<stamp>/session.json` holds the
|
||||
same fields (`mpd` required; no `key` / `pssh` for clear streams).
|
||||
|
||||
Replay:
|
||||
|
||||
```powershell
|
||||
ffplay -user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36" -i "<mpd url>"
|
||||
```
|
||||
|
||||
PC needs a UK egress IP (VPN). Some CDNs reject HEAD; use GET / ffplay as above.
|
||||
224
docs/providers/rte.md
Normal file
224
docs/providers/rte.md
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
# RTE module — filling in `module.yaml`
|
||||
|
||||
DASH + ModularDrm Widevine, captured from the phone. There is no public catalog, so
|
||||
every channel needs one phone capture to bootstrap.
|
||||
|
||||
Create `apps/modules/rte/module.yaml`. It is gitignored. Nothing below is shipped
|
||||
in the repo — you obtain it yourself from your own device.
|
||||
|
||||
## Skeleton
|
||||
|
||||
```yaml
|
||||
package: <android package id>
|
||||
license_url: "<Widevine ModularDrm endpoint, incl. its account query string>"
|
||||
proxy_bin: bin/proxy-android-arm64
|
||||
ca_hash: "6c3578b4"
|
||||
|
||||
score:
|
||||
hosts:
|
||||
- <manifest CDN host>
|
||||
- <ad-stitching host, if the app uses one>
|
||||
deny:
|
||||
- <EPG / schedules host fragment>
|
||||
|
||||
aliases:
|
||||
one: chanone
|
||||
two: chantwo
|
||||
|
||||
channels:
|
||||
chanone:
|
||||
label: "Channel One"
|
||||
chip_desc: ["^chanone$"]
|
||||
play_desc: ["play\\s*chanone"]
|
||||
chantwo:
|
||||
label: "Channel Two"
|
||||
chip_desc: ["^chantwo$"]
|
||||
play_desc: ["play\\s*chantwo"]
|
||||
|
||||
# Origin encoders behind the live channels.
|
||||
origins:
|
||||
vc11: "https://<origin host>/live/<path>/vc11/vc11.isml/"
|
||||
vc12: "https://<origin host>/live/<path>/vc12/vc12.isml/"
|
||||
|
||||
kids:
|
||||
vc11: "<uuid-form KID>"
|
||||
vc12: "<uuid-form KID>"
|
||||
```
|
||||
|
||||
## Where each value comes from
|
||||
|
||||
### `package`
|
||||
|
||||
Open the app on the phone, then:
|
||||
|
||||
```bash
|
||||
adb shell dumpsys window | grep mCurrentFocus
|
||||
```
|
||||
|
||||
The part before `/` is the package id.
|
||||
|
||||
### `license_url`
|
||||
|
||||
Run a discover session and play a channel ([capture.md](capture.md)):
|
||||
|
||||
```bash
|
||||
./bin/drm proxy discover --install-ca --reinject
|
||||
grep -iE 'ModularDrm|widevine|license' outputs/discover/*/appproxy.log
|
||||
```
|
||||
|
||||
Take the full URL including its query string — the account reference in it is part
|
||||
of the endpoint. The module also accepts it per run:
|
||||
|
||||
```bash
|
||||
./bin/drm capture --app rte --rte.license-url 'https://.../ModularDrm?...'
|
||||
```
|
||||
|
||||
### `score.hosts` / `score.deny`
|
||||
|
||||
`hosts` are the hosts that legitimately serve manifests; `deny` are hosts or URL
|
||||
fragments that must never be mistaken for one.
|
||||
|
||||
```bash
|
||||
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
|
||||
| sort | uniq -c | sort -rn | head -20
|
||||
```
|
||||
|
||||
Put the origin and any ad-stitching host in `hosts`. Put EPG/schedule feeds and
|
||||
analytics in `deny` — this app's schedule feed otherwise scores as a manifest
|
||||
because its URL looks plausible.
|
||||
|
||||
Format-level scoring (`.mpd`, `.m3u8`, `/manifest`, `.isml`) is built in; you only
|
||||
supply the provider-specific parts.
|
||||
|
||||
### `channels.*.chip_desc` and `play_desc`
|
||||
|
||||
These are the regexes that locate the channel chip on the Live tab and the large
|
||||
hero Play button. They match the node **content-description**, case-insensitively.
|
||||
|
||||
Navigate to the Live tab by hand and dump the tree:
|
||||
|
||||
```bash
|
||||
adb shell uiautomator dump /sdcard/ui.xml
|
||||
adb shell cat /sdcard/ui.xml | grep -oE 'content-desc="[^"]*"' | sort -u
|
||||
```
|
||||
|
||||
You are looking for two things:
|
||||
|
||||
- a small chip per channel — its description is usually the bare channel name, so
|
||||
anchor the regex (`^chanone$`) to avoid matching a longer label
|
||||
- a large "play <channel>" button that appears after the chip is tapped
|
||||
|
||||
The module taps the hero Play directly when it is already on screen, otherwise it
|
||||
taps the chip first and waits for Play to appear. A match is only accepted as the
|
||||
hero button if its area is at least `hero_min_area` (default 200000), which keeps
|
||||
small list-item play icons from winning.
|
||||
|
||||
### `origins` and `kids` — the important pair
|
||||
|
||||
These let the manifest rewriter keep working after the ad-stitched manifest session
|
||||
expires. Both come from a capture.
|
||||
|
||||
**The KID** is in the capture session:
|
||||
|
||||
```bash
|
||||
./bin/drm capture --app rte --channel chanone --auto-play --wvd data/device.wvd
|
||||
jq -r '.kid' outputs/rte/latest/session.json
|
||||
```
|
||||
|
||||
Convert it to dashed UUID form for the `kids:` map
|
||||
(`8-4-4-4-12`), e.g. `df1633821dddfdd5bec9822c1ec0f052` becomes
|
||||
`df163382-1ddd-fdd5-bec9-822c1ec0f052`. Either form is accepted, but UUID form is
|
||||
easier to read.
|
||||
|
||||
**The origin** is the Smooth Streaming base URL. Find it in the discover traffic:
|
||||
|
||||
```bash
|
||||
grep -oE 'https?://[^"]+\.isml[^"]*' outputs/discover/*/appproxy_traffic.jsonl \
|
||||
| sed -E 's#(\.isml).*#\1/#' | sort -u
|
||||
```
|
||||
|
||||
The last path element before `.isml` is the encoder channel id, which becomes the
|
||||
map key. So `https://host/live/tc-1/vc11/vc11.isml/` is keyed `vc11`.
|
||||
|
||||
**Pair them up** by capturing each channel in turn and noting which KID appeared:
|
||||
|
||||
```bash
|
||||
for ch in chanone chantwo; do
|
||||
./bin/drm capture --app rte --channel $ch --auto-play --wvd data/device.wvd
|
||||
echo "$ch -> $(jq -r .kid outputs/rte/latest/session.json)"
|
||||
done
|
||||
```
|
||||
|
||||
Then confirm the mapping resolves:
|
||||
|
||||
```bash
|
||||
go -C apps/modules test -tags live ./rte/ -v
|
||||
```
|
||||
|
||||
That test fetches each configured origin's manifest, checks the KID resolves back
|
||||
to its channel, and verifies the synthetic manifest lands on the live encoder's
|
||||
segment grid. If a KID does not resolve, `kids:` and `origins:` disagree.
|
||||
|
||||
### `ca_hash`
|
||||
|
||||
The subject hash of the MITM CA, which is what the Magisk mount installs as
|
||||
`<hash>.0`. `6c3578b4` is the bundled CA; change it only if you generated your own.
|
||||
|
||||
```bash
|
||||
adb shell 'ls /system/etc/security/cacerts/'
|
||||
```
|
||||
|
||||
## Optional tuning
|
||||
|
||||
All of these have working defaults — set them only if the app changes.
|
||||
|
||||
```yaml
|
||||
encoder_channel_re: '^(vc|channel)\d+$' # which origin ids use the encoder timeline
|
||||
origin_marker: .isml # path element identifying an origin BaseURL
|
||||
hero_min_area: 200000 # smallest node accepted as the hero Play
|
||||
|
||||
ui:
|
||||
launch_desc_contains: ["live tab", "header logo"]
|
||||
live_tab_desc_re: ["Live tab"]
|
||||
|
||||
timeouts:
|
||||
launch_wait_ui: 25s
|
||||
live_tab: 30s
|
||||
chip_wait: 20s
|
||||
play_wait: 15s
|
||||
playback: 45s
|
||||
|
||||
synthetic:
|
||||
video_timescale: 600
|
||||
audio_timescale: 48000
|
||||
video_name: video
|
||||
audio_name: audio_128k
|
||||
video_bandwidth: 6000000
|
||||
audio_bandwidth: 128000
|
||||
width: 1920
|
||||
height: 1080
|
||||
live_edge_offset_s: 12
|
||||
window_segments: 6
|
||||
```
|
||||
|
||||
The `synthetic` block describes the origin encoder. Its defaults match a common
|
||||
Smooth-to-DASH setup; if your origin uses different stream names or bitrates, read
|
||||
them off the origin manifest:
|
||||
|
||||
```bash
|
||||
curl -s 'https://<origin>/live/<path>/vc11/vc11.isml/Manifest' | head -40
|
||||
```
|
||||
|
||||
`StreamIndex Name="…"` gives `video_name` / `audio_name`, and `Bitrate="…"` gives
|
||||
the bandwidths. Segment duration and phase are learned at runtime, not configured.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
./bin/drm modules # channel count should match your file
|
||||
./bin/drm capture --app rte --channel chanone --auto-play --wvd data/device.wvd
|
||||
go -C apps/modules test -tags live ./rte/ -v
|
||||
```
|
||||
|
||||
A good capture yields `auth`, `pid`, `pssh` and `mpd`, and a single `KID:KEY` whose
|
||||
KID matches the `kids:` entry for that channel's encoder.
|
||||
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` |
|
||||
252
docs/quickstart.md
Normal file
252
docs/quickstart.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# Quickstart
|
||||
|
||||
From an empty directory to a captured Widevine key.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| Need | Version | Needed for |
|
||||
|---|---|---|
|
||||
| Go | 1.25+ | everything |
|
||||
| Python | 3.10+ | the CDM helper (`apps/wvkey/wvkey.py`) |
|
||||
| adb | any recent platform-tools, on `PATH` | phone capture only |
|
||||
| `.wvd` device file | — | fetching content keys |
|
||||
| Rooted Android phone (Magisk) | — | phone capture only |
|
||||
|
||||
Check Go:
|
||||
|
||||
```bash
|
||||
go version
|
||||
```
|
||||
|
||||
If Go is installed but not on `PATH`, set `GOROOT` and prepend its `bin`:
|
||||
|
||||
```bash
|
||||
export GOROOT=/path/to/go
|
||||
export PATH="$GOROOT/bin:$PATH"
|
||||
```
|
||||
|
||||
## 2. Clone and build
|
||||
|
||||
```bash
|
||||
git clone https://git.nulldrm.com/nulldrm/Null-DRM-Official.git
|
||||
cd Null-DRM-Official
|
||||
|
||||
go -C apps/cli build -o ../../bin/drm . # Windows: -o ../../bin/drm.exe
|
||||
```
|
||||
|
||||
That single command builds the whole toolchain: capture, catalog, agent, the
|
||||
streamd control plane, the MITM host CLI, and every app module.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./bin/drm help
|
||||
./bin/drm modules
|
||||
```
|
||||
|
||||
Expected on a fresh clone — modules exist, values do not:
|
||||
|
||||
```text
|
||||
APP PACKAGE KEY MODE REWRITER CHANNELS VALUES
|
||||
rte (not configured) modulardrm rte 0 missing: .../apps/modules/rte/module.yaml
|
||||
tg4 (not configured) raw none 0 missing: .../apps/modules/tg4/module.yaml
|
||||
```
|
||||
|
||||
## 3. The Python CDM helper
|
||||
|
||||
Only `wvkey.py` is Python; it turns a PSSH + license URL into `KID:KEY` lines.
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r apps/wvkey/requirements.txt
|
||||
```
|
||||
|
||||
On Windows:
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\pip install -r apps\wvkey\requirements.txt
|
||||
```
|
||||
|
||||
The binary finds `.venv` automatically; override with `--python /path/to/python`.
|
||||
|
||||
Place your Widevine device file under `data/` (gitignored):
|
||||
|
||||
```text
|
||||
data/device.wvd
|
||||
```
|
||||
|
||||
## 4. Add values for a module
|
||||
|
||||
Create the module's values file. It is gitignored, so it never leaves your machine.
|
||||
|
||||
```bash
|
||||
$EDITOR apps/modules/tg4/module.yaml
|
||||
```
|
||||
|
||||
Minimum for TG4 — see [providers/tg4.md](providers/tg4.md) for where each comes from:
|
||||
|
||||
```yaml
|
||||
package: <android package id>
|
||||
account_id: "<brightcove account id>"
|
||||
policy_key: "BCpkAD..."
|
||||
playback_config_url: "https://.../playback_config.json"
|
||||
|
||||
channels:
|
||||
ioi:
|
||||
label: "Main channel"
|
||||
video_id: "<brightcove video id>"
|
||||
token_field: <field name in the playback config>
|
||||
live_card_index: 0
|
||||
```
|
||||
|
||||
Confirm it loaded:
|
||||
|
||||
```bash
|
||||
./bin/drm modules
|
||||
./bin/drm catalog --app tg4 --list
|
||||
```
|
||||
|
||||
Nothing stored? Pass values per run instead:
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app tg4 --channel ioi \
|
||||
--tg4.account-id 1234567890 --tg4.policy-key 'BCpkAD...'
|
||||
```
|
||||
|
||||
Or through the environment (`<APP>_<FIELD>`, upper snake case):
|
||||
|
||||
```bash
|
||||
export TG4_POLICY_KEY='BCpkAD...'
|
||||
```
|
||||
|
||||
Precedence is **flag → environment → module.yaml → built-in default**. Only
|
||||
mechanical defaults (timeouts, DASH timescales, card geometry) have built-ins.
|
||||
|
||||
## 5. Get keys without a phone
|
||||
|
||||
If the app has a public playback catalog:
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd
|
||||
```
|
||||
|
||||
```text
|
||||
manifest: https://.../playlist-hls-dvr.m3u8
|
||||
license_url: https://.../lic/wv?token=...
|
||||
pssh: AAAAYHBzc2gAAAAA7e+LqXnWSs6j...
|
||||
key: 27c38b955b983ac2827204f8d260d628:f31efbc58fc89526cce5e5dee50a7ebd
|
||||
key[0]: 5b9d58edf81f3b5b9c3cc61f3e81845e:688236976dcf8cdde5cdfac4a698f256
|
||||
...
|
||||
```
|
||||
|
||||
Add `--json` for a machine-readable object, `--out` to write a session directory
|
||||
without fetching keys.
|
||||
|
||||
## 6. Get keys from the phone
|
||||
|
||||
One-time device setup:
|
||||
|
||||
```bash
|
||||
adb devices # your phone must be listed and authorised
|
||||
./bin/drm proxy build # cross-compiles the on-device MITM (linux/arm64)
|
||||
./bin/drm proxy install-ca --reinject
|
||||
```
|
||||
|
||||
`install-ca` pushes the MITM CA and Magisk-mounts it into the system trust store.
|
||||
Without it apps fail TLS with `unknown certificate`. See [capture.md](capture.md)
|
||||
if the reinject does not report success.
|
||||
|
||||
Then capture:
|
||||
|
||||
```bash
|
||||
./bin/drm capture --app rte --channel rteone --auto-play --wvd data/device.wvd
|
||||
```
|
||||
|
||||
What it does: start the on-device MITM → point the phone's HTTP proxy at it →
|
||||
reinject the CA → launch the app → drive its UI to the channel → wait for the
|
||||
license/manifest traffic → run the CDM → write a session → close the app.
|
||||
|
||||
```text
|
||||
[*] Launching rte (...)
|
||||
[*] Auto-play RTE One…
|
||||
[*] tap_ui "Live"/"Live tab, 2 out of 5" @ 324,2253
|
||||
[*] tap Play @ 540,720
|
||||
[+] ... is PLAYING
|
||||
[+] Capture:
|
||||
pid: 8T8Ohq7XT5KN
|
||||
mpd: https://dai.google.com/linear/dash/pa/event/...
|
||||
[+] key: df1633821dddfdd5bec9822c1ec0f052:b6324f76d2bb4dfe63e4272f803274f4
|
||||
[+] session: outputs/rte/20261005-215448/session.json
|
||||
```
|
||||
|
||||
Keep the screen on for the whole run:
|
||||
|
||||
```bash
|
||||
adb shell svc power stayon true
|
||||
```
|
||||
|
||||
Drop `--auto-play` to launch the app and navigate by hand, or drop `--app`
|
||||
entirely for passive mode — the MITM records whatever you play.
|
||||
|
||||
## 7. Control plane
|
||||
|
||||
```bash
|
||||
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8083>. The app and channel pickers are populated from
|
||||
`GET /api/apps`, i.e. from the modules compiled into the binary.
|
||||
|
||||
Create a stream from a capture:
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd --json \
|
||||
> /tmp/s.json
|
||||
|
||||
curl -X POST http://127.0.0.1:8083/api/streams \
|
||||
-H 'Authorization: Bearer SECRET' -H 'Content-Type: application/json' \
|
||||
--data @/tmp/s.json
|
||||
```
|
||||
|
||||
## 8. Agent
|
||||
|
||||
The agent polls streamd, queues any enabled stream that is down or missing
|
||||
credentials, claims a free phone, re-captures it and POSTs the result back.
|
||||
|
||||
```bash
|
||||
export STREAMD_TOKEN=SECRET
|
||||
./bin/drm agent run --config apps/agent/agent.yaml
|
||||
|
||||
./bin/drm agent devices # which phones it can use
|
||||
./bin/drm agent status # recent jobs
|
||||
./bin/drm agent enqueue --stream tg4-ioi --reason manual
|
||||
```
|
||||
|
||||
Details in [streamd.md](streamd.md).
|
||||
|
||||
## 9. Tests
|
||||
|
||||
```bash
|
||||
go -C apps/pkg test ./...
|
||||
go -C apps/modules test ./...
|
||||
go -C apps/streamd test ./...
|
||||
```
|
||||
|
||||
Live-origin tests are tag-gated and need your local values:
|
||||
|
||||
```bash
|
||||
go -C apps/modules test -tags live ./rte/ -v
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause |
|
||||
|---|---|
|
||||
| `unknown app "x"` | not compiled in — check `apps/modules/all/all.go`, then rebuild |
|
||||
| `channels 0` in `drm modules` | no `module.yaml`, or `channels:` is empty |
|
||||
| `unknown channel "y"` | id not in `channels:` and not in `aliases:` |
|
||||
| `tls: unknown certificate` on the phone | CA not mounted — `drm proxy install-ca --reinject` |
|
||||
| `wvkey requires --wvd` | pass `--wvd path/to/device.wvd` |
|
||||
| `timed out waiting for capture` | playback never started, or the app used a path the MITM missed — see [capture.md](capture.md) |
|
||||
| `INVALID_POLICY_KEY` | wrong or stale `policy_key` |
|
||||
183
docs/streamd.md
Normal file
183
docs/streamd.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# Control plane and agent
|
||||
|
||||
Two long-running pieces: `drm serve` holds the state and the dashboard, `drm agent
|
||||
run` keeps credentials fresh using real phones.
|
||||
|
||||
## drm serve
|
||||
|
||||
```bash
|
||||
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--bind` | `0.0.0.0:8083` | listen address |
|
||||
| `--data` | `.cache/streamd` | SQLite, work dirs, HLS output, logs |
|
||||
| `--token` | `$STREAMD_TOKEN` | bearer token for mutating routes |
|
||||
| `--nre` | `$NRE_PATH`, `bin/`, `PATH` | downloader binary |
|
||||
| `--ffmpeg` | `$FFMPEG_PATH`, `bin/`, `PATH` | ffmpeg |
|
||||
| `--mp4decrypt` | `$MP4DECRYPT_PATH`, `bin/`, `PATH` | decrypter |
|
||||
|
||||
Open <http://127.0.0.1:8083>. GET routes are unauthenticated; anything mutating
|
||||
needs `Authorization: Bearer <token>`.
|
||||
|
||||
### API
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/health` | liveness + uptime |
|
||||
| `GET /api/apps` | compiled-in modules, their channels, registered rewriters |
|
||||
| `GET /api/streams` | all streams with runtime state |
|
||||
| `POST /api/streams` | create |
|
||||
| `PATCH /api/streams/:id` | update |
|
||||
| `DELETE /api/streams/:id` | remove |
|
||||
| `POST /api/streams/:id/start\|stop\|restart` | desired state |
|
||||
| `POST /api/streams/:id/credentials` | push a fresh manifest + keys |
|
||||
| `POST /api/streams/:id/claim` | agent claims the stream |
|
||||
|
||||
The dashboard's app and channel pickers are filled from `/api/apps`, so they reflect
|
||||
whatever modules the binary was built with. Nothing about a provider is hardcoded in
|
||||
the UI or the schema.
|
||||
|
||||
### Creating a stream
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd --json > s.json
|
||||
|
||||
curl -X POST http://127.0.0.1:8083/api/streams \
|
||||
-H 'Authorization: Bearer SECRET' -H 'Content-Type: application/json' \
|
||||
--data @s.json
|
||||
```
|
||||
|
||||
A stream created without `app`, `video_select`, `audio_select` or `rewriter` keeps
|
||||
those empty on purpose. They are resolved when the stream starts:
|
||||
|
||||
```text
|
||||
stored row -> the stream's app module (app.StreamDefaults) -> neutral defaults
|
||||
```
|
||||
|
||||
So a module decides its own downloader settings, an operator override in the row
|
||||
still wins, and an unknown app gets something harmless rather than another
|
||||
provider's preferences.
|
||||
|
||||
### Refreshing credentials
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8083/api/streams/1/credentials \
|
||||
-H 'Authorization: Bearer SECRET' -H 'Content-Type: application/json' \
|
||||
-d '{"mpd":"https://...","key":"KID:KEY\nKID:KEY","headers_json":"{}"}'
|
||||
```
|
||||
|
||||
This is exactly what the agent does after a successful capture.
|
||||
|
||||
### Manifest rewriting
|
||||
|
||||
If a stream's `rewriter` names a registered rewriter and the manifest is not HLS,
|
||||
the supervisor starts a local rewrite server and points the downloader at it:
|
||||
|
||||
```text
|
||||
stream rewriter="rte" -> mpd.Lookup("rte") -> http://127.0.0.1:PORT/manifest.mpd
|
||||
```
|
||||
|
||||
The log line is `stream X: rewritten MPD http://127.0.0.1:…`. Streamd never imports
|
||||
a provider package to do this — the module registered the rewriter under that name
|
||||
at init time.
|
||||
|
||||
## drm agent run
|
||||
|
||||
```bash
|
||||
export STREAMD_TOKEN=SECRET
|
||||
./bin/drm agent run --config apps/agent/agent.yaml
|
||||
```
|
||||
|
||||
What it does, in a loop:
|
||||
|
||||
1. poll streamd for enabled streams that are down or missing credentials
|
||||
2. enqueue a job in a durable SQLite queue
|
||||
3. claim a free ADB device that already has the app installed
|
||||
4. run a phone capture through the stream's app module
|
||||
5. `POST /api/streams/:id/credentials`
|
||||
6. force-stop the app and release the device
|
||||
|
||||
It never installs apps and never runs a downloader. A websocket heartbeat keeps its
|
||||
claims alive, so claims expire automatically if the agent dies.
|
||||
|
||||
### Subcommands
|
||||
|
||||
```bash
|
||||
./bin/drm agent devices # phones it can use
|
||||
./bin/drm agent status # recent jobs
|
||||
./bin/drm agent enqueue --stream tg4-ioi --reason manual
|
||||
./bin/drm agent cancel --job 3
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
`apps/agent/agent.yaml`:
|
||||
|
||||
```yaml
|
||||
streamd_url: http://127.0.0.1:8083
|
||||
# token: taken from $STREAMD_TOKEN when unset
|
||||
poll_interval_sec: 30
|
||||
data_dir: .cache/agent
|
||||
workers: 1
|
||||
max_attempts: 5
|
||||
capture_wait_sec: 180
|
||||
# devices: [SERIAL1, SERIAL2] # empty = any authorised device
|
||||
# python: path to python for wvkey.py
|
||||
# wvd: data/device.wvd
|
||||
# user_agent: ""
|
||||
# agent_id: defaults to hostname
|
||||
```
|
||||
|
||||
There is no modules directory to configure — app modules are compiled into the
|
||||
binary.
|
||||
|
||||
`wvd` must be set (here or on the phone capture) or key fetches fail.
|
||||
|
||||
## Running both locally
|
||||
|
||||
```bash
|
||||
# terminal 1
|
||||
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
|
||||
|
||||
# terminal 2
|
||||
export STREAMD_TOKEN=SECRET
|
||||
./bin/drm agent run --config apps/agent/agent.yaml
|
||||
```
|
||||
|
||||
Then create a stream, enable it, and watch the agent pick it up:
|
||||
|
||||
```bash
|
||||
./bin/drm agent status
|
||||
```
|
||||
|
||||
## External tools
|
||||
|
||||
The media supervisor shells out to three binaries. Put them on `PATH`, in `bin/`,
|
||||
or name them with flags/env:
|
||||
|
||||
| Tool | Flag | Env |
|
||||
|---|---|---|
|
||||
| N_m3u8DL-RE | `--nre` | `NRE_PATH`, `N_M3U8DL_RE` |
|
||||
| ffmpeg | `--ffmpeg` | `FFMPEG_PATH`, `FFMPEG` |
|
||||
| mp4decrypt | `--mp4decrypt` | `MP4DECRYPT_PATH`, `MP4DECRYPT` |
|
||||
|
||||
Streams can be created, claimed and credentialed without any of them; only actually
|
||||
pulling media needs them. The supervisor is the least exercised part of the system —
|
||||
treat its health reporting as control-plane bookkeeping.
|
||||
|
||||
## Data
|
||||
|
||||
```text
|
||||
.cache/streamd/
|
||||
streamd.db SQLite: streams, runtime, claims
|
||||
work/<name>/ downloader scratch + keys.txt
|
||||
www/<name>/ HLS output
|
||||
logs/ per-stream downloader + ffmpeg logs
|
||||
|
||||
.cache/agent/
|
||||
queue.db durable job queue
|
||||
```
|
||||
|
||||
Back up `streamd.db` before upgrading — schema defaults change between versions.
|
||||
Loading…
Add table
Add a link
Reference in a new issue