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.
12 KiB
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 first — you need the app's package id, license URL, manifest host and UI selectors before you can write any of this.
Anatomy
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.
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
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
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
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
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:
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.
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:
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.
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
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
// apps/modules/all/all.go
import (
_ "drmdecryption/modules/myapp"
_ "drmdecryption/modules/rte"
_ "drmdecryption/modules/tg4"
)
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:
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:build live
go -C apps/modules test -tags live ./myapp/ -v
Checklist
- No account id, key, token, video id, hostname or KID in any
.gofile module.yamlis gitignored (git check-ignore -v apps/modules/myapp/module.yaml)New()succeeds with nomodule.yaml- Launch marker actually matches — watch for a silent full-timeout wait
CaptureHintsasks only for fields the app really producesKeyModeis correct (rawfor binary challenges,modulardrmfor JSON)- Added to
all/all.go drm moduleslists it with the right channel count