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

379 lines
12 KiB
Markdown

# 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