Null-DRM-Official/docs/architecture.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

241 lines
9.9 KiB
Markdown

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