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
67
apps/modules/README.md
Normal file
67
apps/modules/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# App modules
|
||||
|
||||
One Go package per streaming app. Every module is **compiled into the binary** —
|
||||
there is no plugin loading and no modules directory to point a binary at.
|
||||
|
||||
```text
|
||||
apps/modules/ Go module: drmdecryption/modules
|
||||
go.mod replace drmdecryption => ../pkg
|
||||
all/all.go blank-imports every module — the link point
|
||||
provider/ shared base: values loading, channel aliases, durations
|
||||
<name>/*.go TRACKED: the app's logic
|
||||
<name>/module.yaml usually GITIGNORED values (bbc is published)
|
||||
```
|
||||
|
||||
## Tracked code, untracked values
|
||||
|
||||
This split is the rule for modules with secrets; BBC is the exception (clear
|
||||
streams — package id + channel map only, so `bbc/module.yaml` is committed):
|
||||
|
||||
| `<name>/*.go` (tracked) | `<name>/module.yaml` (gitignored except bbc) |
|
||||
|---|---|
|
||||
| launch + auto-play sequence | android package id |
|
||||
| navigation idioms | 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 selectors, labels, aliases |
|
||||
|
||||
Module Go source contains **no** account id, policy key, video id, license URL,
|
||||
origin host or KID. Modules with secrets have a test asserting an empty config
|
||||
yields empty credentials:
|
||||
|
||||
```bash
|
||||
go -C apps/modules test ./... -run NoHardcoded -v
|
||||
```
|
||||
|
||||
Values arrive by **flag → environment → module.yaml → Go default**, and only
|
||||
mechanical defaults (timeouts, DASH timescales, card geometry) live in code:
|
||||
|
||||
```bash
|
||||
./bin/drm catalog --app tg4 --channel ioi --tg4.policy-key BCpkAD...
|
||||
export TG4_POLICY_KEY=BCpkAD... # same thing
|
||||
```
|
||||
|
||||
## Adding a module
|
||||
|
||||
1. Create `apps/modules/myapp/` with `config.go` and `myapp.go`
|
||||
2. Register from `init()`: `appreg.Register(Name, New)` and
|
||||
`appreg.RegisterFlags(bindFlags)`
|
||||
3. Add one line to `all/all.go`: `_ "drmdecryption/modules/myapp"`
|
||||
4. Create `apps/modules/myapp/module.yaml` with your values
|
||||
5. `go -C apps/cli build -o ../../bin/drm .` then `./bin/drm modules`
|
||||
|
||||
**Authoring guide: [docs/modules.md](../../docs/modules.md)** — the full contract,
|
||||
`uiflow` reference, optional capability interfaces, and a checklist.
|
||||
|
||||
**Finding the values:** [docs/capture.md](../../docs/capture.md) for a new app,
|
||||
[docs/providers/](../../docs/providers/) for the modules already here.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./bin/drm modules # what is compiled in
|
||||
./bin/drm capture --app rte --channel rteone --auto-play
|
||||
./bin/drm capture --app bbc --channel bbcone # transparent MITM; play on phone; MPD only
|
||||
./bin/drm catalog --app tg4 --channel ioi --keys --wvd data/device.wvd
|
||||
./bin/drm agent run --config apps/agent/agent.yaml
|
||||
```
|
||||
10
apps/modules/all/all.go
Normal file
10
apps/modules/all/all.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Package all links every app module into the binary. Importing it for side
|
||||
// effects is what makes `--app <name>` work; adding a provider means adding one
|
||||
// line here.
|
||||
package all
|
||||
|
||||
import (
|
||||
_ "drmdecryption/modules/bbc"
|
||||
_ "drmdecryption/modules/rte"
|
||||
_ "drmdecryption/modules/tg4"
|
||||
)
|
||||
72
apps/modules/all/all_test.go
Normal file
72
apps/modules/all/all_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package all
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
appreg "drmdecryption/app"
|
||||
)
|
||||
|
||||
// Every module must be constructible with no module.yaml present — a fresh clone
|
||||
// has none, and the binary still has to start and list its providers.
|
||||
func TestAllModulesRegisterAndConstruct(t *testing.T) {
|
||||
names := appreg.Names()
|
||||
if len(names) == 0 {
|
||||
t.Fatal("no app modules registered — check the blank imports in all.go")
|
||||
}
|
||||
for _, n := range names {
|
||||
a, err := appreg.Get(n)
|
||||
if err != nil {
|
||||
t.Fatalf("construct %s: %v", n, err)
|
||||
}
|
||||
if a.Name() != n {
|
||||
t.Errorf("%s: Name() = %q", n, a.Name())
|
||||
}
|
||||
switch a.KeyMode() {
|
||||
case "raw", "modulardrm", "none":
|
||||
default:
|
||||
t.Errorf("%s: KeyMode() = %q, want raw, modulardrm, or none", n, a.KeyMode())
|
||||
}
|
||||
if a.CAHash() == "" {
|
||||
t.Errorf("%s: CAHash() empty", n)
|
||||
}
|
||||
if a.ProxyBin() == "" {
|
||||
t.Errorf("%s: ProxyBin() empty", n)
|
||||
}
|
||||
if a.MPDRewriter() == nil {
|
||||
t.Errorf("%s: MPDRewriter() nil", n)
|
||||
}
|
||||
if a.CaptureHints().RemoteLog == "" {
|
||||
t.Errorf("%s: CaptureHints has no remote log", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag binding must not construct any app, so overrides land before config load.
|
||||
func TestBindFlagsRegistersOverrides(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
appreg.BindFlags(fs)
|
||||
count := 0
|
||||
fs.VisitAll(func(*flag.Flag) { count++ })
|
||||
if count == 0 {
|
||||
t.Fatal("no module override flags registered")
|
||||
}
|
||||
}
|
||||
|
||||
// A module declaring a rewriter must have registered it under that name.
|
||||
func TestDeclaredRewritersAreRegistered(t *testing.T) {
|
||||
for _, a := range appreg.All() {
|
||||
sd, ok := a.(appreg.StreamDefaults)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := sd.RewriterName()
|
||||
if name == "" || name == "none" {
|
||||
continue
|
||||
}
|
||||
if a.MPDRewriter().Name() != name {
|
||||
t.Errorf("%s: RewriterName()=%q but MPDRewriter().Name()=%q",
|
||||
a.Name(), name, a.MPDRewriter().Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
129
apps/modules/bbc/bbc.go
Normal file
129
apps/modules/bbc/bbc.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Package bbc is the BBC iPlayer app module: clear DASH/HLS captured through
|
||||
// transparent MITM (UK VPN stays on). Only the manifest URL is required — no
|
||||
// Widevine keys for the mobile-phone-main mediaset streams we capture.
|
||||
package bbc
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/mpd"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
appreg.Register(Name, New)
|
||||
appreg.RegisterFlags(bindFlags)
|
||||
}
|
||||
|
||||
func bindFlags(fs *flag.FlagSet) {
|
||||
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||
}
|
||||
|
||||
// App is the BBC iPlayer plugin.
|
||||
type App struct {
|
||||
*provider.Base
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New constructs the plugin from module.yaml + flags + env.
|
||||
func New() (appreg.App, error) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := make(map[string]string, len(cfg.Channels))
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// KeyMode none: mobile streams are clear (CDN signed URLs); no wvkey.
|
||||
func (a *App) KeyMode() string { return "none" }
|
||||
|
||||
// CaptureHints: only the MPD/HLS master from mediaselector is required.
|
||||
func (a *App) CaptureHints() capture.Hints {
|
||||
return a.Hints("mpd")
|
||||
}
|
||||
|
||||
// UseTransparentMITM: NordVPN UK conflicts with Wi‑Fi http_proxy.
|
||||
func (a *App) UseTransparentMITM() bool { return true }
|
||||
|
||||
// MPDRewriter: passthrough — no rewrite needed for clear BBC DASH.
|
||||
func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||
|
||||
// Channels lists configured live targets.
|
||||
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
|
||||
}
|
||||
|
||||
// Launch cold-starts iPlayer.
|
||||
func (a *App) Launch(c *adb.Client) error {
|
||||
c.EnsureAwake()
|
||||
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||
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()
|
||||
if err := uiflow.WaitUI(c, a.CacheDir(), uiflow.Match{
|
||||
DescContains: a.cfg.UI.LaunchDescContains,
|
||||
ResourceContains: a.cfg.UI.LaunchResourceContains,
|
||||
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[!] launch UI wait: %v — continue and play manually\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoPlay: best-effort; iPlayer UI varies — operator can play by hand.
|
||||
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||
id, err := a.Resolve(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch := a.cfg.Channels[id]
|
||||
fmt.Printf("[*] Auto-play %s — open Live and start playback on the phone…\n", a.Label(id))
|
||||
if ch.VPID != "" {
|
||||
fmt.Printf(" expected mediaselector vpid ≈ %s\n", ch.VPID)
|
||||
}
|
||||
c.DismissShadeIfFocused()
|
||||
if len(ch.PlayDesc) == 0 && len(ch.ChipDesc) == 0 {
|
||||
return fmt.Errorf("no UI selectors for %s — play manually on the phone", id)
|
||||
}
|
||||
if len(ch.ChipDesc) > 0 {
|
||||
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{DescRE: ch.ChipDesc}, 20*time.Second); err != nil {
|
||||
return fmt.Errorf("channel chip: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if len(ch.PlayDesc) > 0 {
|
||||
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{DescRE: ch.PlayDesc}, 15*time.Second); err != nil {
|
||||
return fmt.Errorf("play: %w", err)
|
||||
}
|
||||
}
|
||||
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||
})
|
||||
}
|
||||
|
||||
// StreamDefaults for clear live DASH.
|
||||
func (a *App) VideoSelect() string { return "res=1280x720:for=best" }
|
||||
func (a *App) AudioSelect() string { return "lang=en:for=best" }
|
||||
func (a *App) RewriterName() string { return "none" }
|
||||
func (a *App) LiveWaitSeconds() int { return 2 }
|
||||
func (a *App) TSReadyBytes() int64 { return 256 << 10 }
|
||||
80
apps/modules/bbc/bbc_test.go
Normal file
80
apps/modules/bbc/bbc_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package bbc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
var (
|
||||
_ appreg.App = (*App)(nil)
|
||||
_ appreg.StreamDefaults = (*App)(nil)
|
||||
_ appreg.TransparentMITM = (*App)(nil)
|
||||
)
|
||||
|
||||
func testApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
cfg := Config{
|
||||
Channels: map[string]Channel{
|
||||
"bbcone": {Label: "BBC One", VPID: "bbc_one_london"},
|
||||
"bbctwo": {Label: "BBC Two", VPID: "bbc_two_england"},
|
||||
},
|
||||
Common: provider.Common{
|
||||
Package: "bbc.iplayer.android",
|
||||
Aliases: map[string]string{"one": "bbcone", "bbc1": "bbcone"},
|
||||
Score: capture.ScoreCfg{
|
||||
Hosts: []string{"open.live.bbc.co.uk", "vs-cmaf-push-uk"},
|
||||
},
|
||||
},
|
||||
}.withDefaults()
|
||||
labels := map[string]string{}
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, modcfg.Result{Path: "test"}), cfg: cfg}
|
||||
}
|
||||
|
||||
func TestNoHardcodedSecrets(t *testing.T) {
|
||||
cfg := Config{}.withDefaults()
|
||||
if cfg.Package != "" || cfg.LicenseURL != "" {
|
||||
t.Fatalf("empty config must not invent package/license: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyModeNoneAndMPDOnly(t *testing.T) {
|
||||
a := testApp(t)
|
||||
if a.KeyMode() != "none" {
|
||||
t.Fatalf("KeyMode = %q, want none", a.KeyMode())
|
||||
}
|
||||
h := a.CaptureHints()
|
||||
if len(h.Require) != 1 || h.Require[0] != "mpd" {
|
||||
t.Fatalf("Require = %v, want [mpd]", h.Require)
|
||||
}
|
||||
if !a.UseTransparentMITM() {
|
||||
t.Fatal("BBC must use transparent MITM (UK VPN)")
|
||||
}
|
||||
if _, ok := a.MPDRewriter().(mpd.Passthrough); !ok {
|
||||
t.Fatal("expected Passthrough rewriter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliases(t *testing.T) {
|
||||
a := testApp(t)
|
||||
id, err := a.Resolve("one")
|
||||
if err != nil || id != "bbcone" {
|
||||
t.Fatalf("Resolve(one) = %q %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureHintsScoreHosts(t *testing.T) {
|
||||
a := testApp(t)
|
||||
h := a.CaptureHints()
|
||||
u := "https://vs-cmaf-push-uk.live.fastly.md.bbci.co.uk/x/mobile.mpd"
|
||||
if h.Score.Score(u) <= 0 {
|
||||
t.Fatalf("expected positive score for %s (score=%d)", u, h.Score.Score(u))
|
||||
}
|
||||
}
|
||||
74
apps/modules/bbc/config.go
Normal file
74
apps/modules/bbc/config.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package bbc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
)
|
||||
|
||||
// Name is the module id (--app bbc).
|
||||
const Name = "bbc"
|
||||
|
||||
// Channel is one live (or catch-up) target. Phone UI selectors are optional —
|
||||
// capture works with manual play; auto-play uses them when present.
|
||||
type Channel struct {
|
||||
Label string `yaml:"label"`
|
||||
// VPID is the mediaselector version/service id (e.g. bbc_one_london).
|
||||
VPID string `yaml:"vpid"`
|
||||
// ChipDesc / PlayDesc are optional UI matchers for --auto-play.
|
||||
ChipDesc []string `yaml:"chip_desc"`
|
||||
PlayDesc []string `yaml:"play_desc"`
|
||||
}
|
||||
|
||||
// Config is apps/modules/bbc/module.yaml.
|
||||
type Config struct {
|
||||
provider.Common `yaml:",inline"`
|
||||
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
|
||||
Timeouts Timeouts `yaml:"timeouts"`
|
||||
UI UI `yaml:"ui"`
|
||||
}
|
||||
|
||||
// Timeouts for launch / optional autoplay.
|
||||
type Timeouts struct {
|
||||
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||
Playback provider.Duration `yaml:"playback"`
|
||||
}
|
||||
|
||||
// UI markers for launch readiness (localized; keep loose).
|
||||
type UI struct {
|
||||
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||
LaunchResourceContains []string `yaml:"launch_resource_contains"`
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
t := &c.Timeouts
|
||||
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(30 * time.Second))
|
||||
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(4 * time.Second))
|
||||
t.Playback = provider.Duration(t.Playback.D(60 * time.Second))
|
||||
if len(c.UI.LaunchDescContains) == 0 {
|
||||
c.UI.LaunchDescContains = []string{"iPlayer", "Home", "Live"}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
var flags struct {
|
||||
packageName string
|
||||
caHash string
|
||||
proxyBin string
|
||||
}
|
||||
|
||||
func loadConfig() (Config, modcfg.Result, error) {
|
||||
var cfg Config
|
||||
res, err := modcfg.Load(Name, &cfg)
|
||||
if err != nil {
|
||||
return cfg, res, err
|
||||
}
|
||||
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||
return cfg.withDefaults(), res, nil
|
||||
}
|
||||
51
apps/modules/bbc/module.yaml
Normal file
51
apps/modules/bbc/module.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# BBC iPlayer — published values. Package id and channel map only;
|
||||
# no Widevine license URL (mobile-phone-main streams are clear).
|
||||
|
||||
package: bbc.iplayer.android
|
||||
proxy_bin: bin/proxy-android-arm64
|
||||
ca_hash: "6c3578b4"
|
||||
|
||||
# Manifest-URL scoring for the on-device MITM / waiter.
|
||||
score:
|
||||
hosts:
|
||||
- open.live.bbc.co.uk
|
||||
- vs-cmaf-push-uk
|
||||
- vod-dash-uk
|
||||
- akamaized.net
|
||||
- bbci.co.uk
|
||||
- bidi.net.uk
|
||||
- bidi.bbc.co.uk
|
||||
deny:
|
||||
- telemetry.api.bbci
|
||||
- bag.api.bbc
|
||||
- ibl.api.bbci
|
||||
- thumbnail
|
||||
|
||||
aliases:
|
||||
one: bbcone
|
||||
bbc1: bbcone
|
||||
london: bbcone
|
||||
two: bbctwo
|
||||
bbc2: bbctwo
|
||||
news: bbcnews
|
||||
four: bbcfour
|
||||
|
||||
channels:
|
||||
bbcone:
|
||||
label: BBC One
|
||||
vpid: bbc_one_london
|
||||
bbctwo:
|
||||
label: BBC Two
|
||||
vpid: bbc_two_england
|
||||
bbcnews:
|
||||
label: BBC News
|
||||
vpid: bbc_news_channel
|
||||
bbcfour:
|
||||
label: BBC Four
|
||||
vpid: bbc_four
|
||||
|
||||
# timeouts:
|
||||
# launch_wait_ui: 30s
|
||||
# playback: 60s
|
||||
# ui:
|
||||
# launch_desc_contains: ["iPlayer", "Home", "Live"]
|
||||
9
apps/modules/go.mod
Normal file
9
apps/modules/go.mod
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module drmdecryption/modules
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require drmdecryption v0.0.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
replace drmdecryption => ../pkg
|
||||
4
apps/modules/go.sum
Normal file
4
apps/modules/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
41
apps/modules/provider/duration.go
Normal file
41
apps/modules/provider/duration.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration is a time.Duration that accepts the spellings a module.yaml uses:
|
||||
// a Go duration string ("25s", "1200ms") or a bare number meaning seconds.
|
||||
// yaml.v3 decodes time.Duration only as raw nanoseconds, which no one writes.
|
||||
type Duration time.Duration
|
||||
|
||||
// D returns the value as a time.Duration, or def when unset.
|
||||
func (d Duration) D(def time.Duration) time.Duration {
|
||||
if d == 0 {
|
||||
return def
|
||||
}
|
||||
return time.Duration(d)
|
||||
}
|
||||
|
||||
func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err == nil {
|
||||
if s == "" {
|
||||
*d = 0
|
||||
return nil
|
||||
}
|
||||
v, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
var f float64
|
||||
if err := unmarshal(&f); err != nil {
|
||||
return fmt.Errorf("duration must be a string like \"25s\" or a number of seconds")
|
||||
}
|
||||
*d = Duration(time.Duration(f * float64(time.Second)))
|
||||
return nil
|
||||
}
|
||||
147
apps/modules/provider/provider.go
Normal file
147
apps/modules/provider/provider.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Package provider holds the plumbing every app module shares: loading its local
|
||||
// values file, channel id normalization and aliasing, and the boilerplate half of
|
||||
// app.App. Module packages embed Base and add their own navigation and catalog.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/phonecap"
|
||||
"drmdecryption/repo"
|
||||
)
|
||||
|
||||
// Common is the part of a module.yaml that every module has. Modules embed it in
|
||||
// their own config struct with `yaml:",inline"`.
|
||||
type Common struct {
|
||||
Package string `yaml:"package"`
|
||||
LicenseURL string `yaml:"license_url"`
|
||||
ProxyBin string `yaml:"proxy_bin"`
|
||||
CAHash string `yaml:"ca_hash"`
|
||||
Aliases map[string]string `yaml:"aliases"`
|
||||
Score capture.ScoreCfg `yaml:"score"`
|
||||
}
|
||||
|
||||
// Base implements the boilerplate half of app.App.
|
||||
type Base struct {
|
||||
name string
|
||||
common Common
|
||||
labels map[string]string // canonical channel id -> label
|
||||
cacheDir string
|
||||
cfg modcfg.Result
|
||||
}
|
||||
|
||||
// New builds a Base. labels maps canonical channel ids to display labels.
|
||||
func New(name string, common Common, labels map[string]string, cfg modcfg.Result) *Base {
|
||||
root := repo.Root()
|
||||
cacheDir := filepath.Join(root, ".cache", "ui-"+name)
|
||||
_ = os.MkdirAll(cacheDir, 0o755)
|
||||
return &Base{name: name, common: common, labels: labels, cacheDir: cacheDir, cfg: cfg}
|
||||
}
|
||||
|
||||
func (b *Base) Name() string { return b.name }
|
||||
func (b *Base) Package() string { return b.common.Package }
|
||||
func (b *Base) LicenseURL() string { return b.common.LicenseURL }
|
||||
func (b *Base) CacheDir() string { return b.cacheDir }
|
||||
|
||||
// ConfigPath / ConfigLoaded report where this module's values came from.
|
||||
func (b *Base) ConfigPath() string { return b.cfg.Path }
|
||||
func (b *Base) ConfigLoaded() bool { return b.cfg.Loaded }
|
||||
|
||||
// ProxyBin resolves the on-device MITM binary, relative paths against repo root.
|
||||
func (b *Base) ProxyBin() string {
|
||||
if b.common.ProxyBin != "" {
|
||||
return modcfg.Resolve(b.common.ProxyBin)
|
||||
}
|
||||
root := repo.Root()
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "bin", "proxy-android-arm64"),
|
||||
filepath.Join(root, "apps", "proxy", "proxy-android-arm64"),
|
||||
} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return filepath.Join(root, "bin", "proxy-android-arm64")
|
||||
}
|
||||
|
||||
func (b *Base) CAHash() string {
|
||||
if b.common.CAHash == "" {
|
||||
return phonecap.DefaultCAHash
|
||||
}
|
||||
return b.common.CAHash
|
||||
}
|
||||
|
||||
// Score is this module's manifest-URL scoring, merged onto the neutral baseline.
|
||||
func (b *Base) Score() capture.ScoreCfg {
|
||||
return capture.DefaultScoreCfg().Merge(b.common.Score)
|
||||
}
|
||||
|
||||
// Hints returns device-layout hints with this module's scoring applied.
|
||||
func (b *Base) Hints(require ...string) capture.Hints {
|
||||
h := capture.DefaultHints()
|
||||
h.Require = require
|
||||
h.Score = b.Score()
|
||||
return h
|
||||
}
|
||||
|
||||
// NormalizeID folds a channel id to its comparison form.
|
||||
func NormalizeID(id string) string {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
for _, ch := range []string{"-", "_", " "} {
|
||||
id = strings.ReplaceAll(id, ch, "")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Resolve maps any spelling or alias of a channel to its canonical id.
|
||||
func (b *Base) Resolve(id string) (string, error) {
|
||||
want := NormalizeID(id)
|
||||
for canon := range b.labels {
|
||||
if NormalizeID(canon) == want {
|
||||
return canon, nil
|
||||
}
|
||||
}
|
||||
for alias, canon := range b.common.Aliases {
|
||||
if NormalizeID(alias) == want {
|
||||
if _, ok := b.labels[canon]; ok {
|
||||
return canon, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
known := make([]string, 0, len(b.labels))
|
||||
for k := range b.labels {
|
||||
known = append(known, k)
|
||||
}
|
||||
return "", fmt.Errorf("unknown channel %q for %s (known: %s) — add it to %s",
|
||||
id, b.name, strings.Join(known, "|"), b.cfg.Path)
|
||||
}
|
||||
|
||||
func (b *Base) HasChannel(id string) bool {
|
||||
_, err := b.Resolve(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Label returns a channel's display label, falling back to its id.
|
||||
func (b *Base) Label(canonID string) string {
|
||||
if l := b.labels[canonID]; l != "" {
|
||||
return l
|
||||
}
|
||||
return canonID
|
||||
}
|
||||
|
||||
// ChannelIDs lists canonical channel ids, sorted so listings and API responses
|
||||
// are stable (map iteration order is not).
|
||||
func (b *Base) ChannelIDs() []string {
|
||||
out := make([]string, 0, len(b.labels))
|
||||
for k := range b.labels {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
118
apps/modules/rte/config.go
Normal file
118
apps/modules/rte/config.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package rte
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
// Name is the module id (--app rte).
|
||||
const Name = "rte"
|
||||
|
||||
// Channel is one live channel's phone-UI selectors and encoder identity. All of
|
||||
// it is data: nothing here is compiled into the binary.
|
||||
type Channel struct {
|
||||
Label string `yaml:"label"`
|
||||
// ChipDesc matches the channel chip on the Live tab.
|
||||
ChipDesc []string `yaml:"chip_desc"`
|
||||
// PlayDesc matches the large hero Play button.
|
||||
PlayDesc []string `yaml:"play_desc"`
|
||||
}
|
||||
|
||||
// Config is apps/modules/rte/module.yaml.
|
||||
type Config struct {
|
||||
provider.Common `yaml:",inline"`
|
||||
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
|
||||
// Origins maps an encoder channel id to its Smooth Streaming origin base.
|
||||
Origins map[string]string `yaml:"origins"`
|
||||
// KIDs maps an encoder channel id to its Widevine KID.
|
||||
KIDs map[string]string `yaml:"kids"`
|
||||
// EncoderChannelRE matches origin channel ids that use the encoder timeline.
|
||||
EncoderChannelRE string `yaml:"encoder_channel_re"`
|
||||
// OriginMarker is the path element identifying an origin BaseURL.
|
||||
OriginMarker string `yaml:"origin_marker"`
|
||||
|
||||
// Synthetic tunes the generated MPD. Everything in it has a safe default.
|
||||
Synthetic mpd.SyntheticConfig `yaml:"synthetic"`
|
||||
|
||||
// HeroMinArea is the smallest node area accepted as the hero Play button.
|
||||
HeroMinArea int `yaml:"hero_min_area"`
|
||||
|
||||
Timeouts Timeouts `yaml:"timeouts"`
|
||||
UI UI `yaml:"ui"`
|
||||
}
|
||||
|
||||
// Timeouts for the launch / autoplay sequence. Written as "25s" in module.yaml.
|
||||
type Timeouts struct {
|
||||
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||
LiveTab provider.Duration `yaml:"live_tab"`
|
||||
ChipWait provider.Duration `yaml:"chip_wait"`
|
||||
PlayWait provider.Duration `yaml:"play_wait"`
|
||||
Playback provider.Duration `yaml:"playback"`
|
||||
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||
AfterLiveTab provider.Duration `yaml:"after_live_tab"`
|
||||
AfterChip provider.Duration `yaml:"after_chip"`
|
||||
}
|
||||
|
||||
// UI holds the markers the launch/autoplay sequence waits on, as config so an
|
||||
// app UI redesign needs no rebuild.
|
||||
type UI struct {
|
||||
// LaunchDescContains: any of these means the app is up.
|
||||
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||
// LiveTabDescRE matches the Live tab to tap.
|
||||
LiveTabDescRE []string `yaml:"live_tab_desc_re"`
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.EncoderChannelRE == "" {
|
||||
c.EncoderChannelRE = `^(vc|channel)\d+$`
|
||||
}
|
||||
if c.OriginMarker == "" {
|
||||
c.OriginMarker = ".isml"
|
||||
}
|
||||
if c.HeroMinArea == 0 {
|
||||
c.HeroMinArea = 200_000
|
||||
}
|
||||
t := &c.Timeouts
|
||||
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(25 * time.Second))
|
||||
t.LiveTab = provider.Duration(t.LiveTab.D(30 * time.Second))
|
||||
t.ChipWait = provider.Duration(t.ChipWait.D(20 * time.Second))
|
||||
t.PlayWait = provider.Duration(t.PlayWait.D(15 * time.Second))
|
||||
t.Playback = provider.Duration(t.Playback.D(45 * time.Second))
|
||||
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(4 * time.Second))
|
||||
t.AfterLiveTab = provider.Duration(t.AfterLiveTab.D(2 * time.Second))
|
||||
t.AfterChip = provider.Duration(t.AfterChip.D(2 * time.Second))
|
||||
if len(c.UI.LaunchDescContains) == 0 {
|
||||
c.UI.LaunchDescContains = []string{"live tab", "header logo"}
|
||||
}
|
||||
if len(c.UI.LiveTabDescRE) == 0 {
|
||||
c.UI.LiveTabDescRE = []string{"Live tab"}
|
||||
}
|
||||
c.Synthetic = c.Synthetic.WithDefaults()
|
||||
return c
|
||||
}
|
||||
|
||||
// flag overrides, bound before any app is constructed.
|
||||
var flags struct {
|
||||
packageName string
|
||||
licenseURL string
|
||||
caHash string
|
||||
proxyBin string
|
||||
}
|
||||
|
||||
func loadConfig() (Config, modcfg.Result, error) {
|
||||
var cfg Config
|
||||
res, err := modcfg.Load(Name, &cfg)
|
||||
if err != nil {
|
||||
return cfg, res, err
|
||||
}
|
||||
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||
cfg.LicenseURL = modcfg.Override(Name, "license_url", flags.licenseURL, cfg.LicenseURL)
|
||||
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||
return cfg.withDefaults(), res, nil
|
||||
}
|
||||
130
apps/modules/rte/live_test.go
Normal file
130
apps/modules/rte/live_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//go:build live
|
||||
|
||||
// Live tests hit the real origin and need this module's local module.yaml.
|
||||
// They are excluded from the normal test run; enable with:
|
||||
//
|
||||
// go -C apps/modules test -tags live ./rte/ -v
|
||||
package rte
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
// The rewriter must publish a usable manifest from the KID alone — this is the
|
||||
// path taken when the ad-stitched upstream session has expired (HTTP 410) and all
|
||||
// that is left is the key from an earlier capture.
|
||||
func TestLiveRewriteFromKIDOnly(t *testing.T) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Loaded {
|
||||
t.Skipf("no local values at %s", res.Path)
|
||||
}
|
||||
if len(cfg.KIDs) == 0 {
|
||||
t.Skip("no kids configured")
|
||||
}
|
||||
|
||||
for channel, kid := range cfg.KIDs {
|
||||
t.Run(channel, func(t *testing.T) {
|
||||
resetCaches(channel)
|
||||
r := NewRewriterWith(cfg)
|
||||
|
||||
// Empty upstream = expired session; only the KID hint is available.
|
||||
out, err := r.Rewrite(nil, kid)
|
||||
if err != nil {
|
||||
t.Fatalf("rewrite from KID: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
origin := cfg.Origins[channel]
|
||||
if origin == "" {
|
||||
t.Fatalf("no origin configured for %s", channel)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`type="dynamic"`,
|
||||
origin + cfg.Synthetic.SegmentPathSuffix,
|
||||
channel + "-" + cfg.Synthetic.VideoName,
|
||||
channel + "-" + cfg.Synthetic.AudioName,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("manifest missing %q", want)
|
||||
}
|
||||
}
|
||||
u, _ := mpd.KIDToUUID(kid)
|
||||
if !strings.Contains(got, `cenc:default_KID="`+u+`"`) {
|
||||
t.Errorf("manifest does not carry KID %s", u)
|
||||
}
|
||||
|
||||
// The timeline must be non-empty and on the encoder grid, or the
|
||||
// downloader asks the origin for segments that do not exist.
|
||||
re := regexp.MustCompile(`<S t="(\d+)" d="(\d+)" r="(\d+)"/>`)
|
||||
ms := re.FindAllStringSubmatch(got, -1)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("want 2 timelines, got %d", len(ms))
|
||||
}
|
||||
grid, err := mpd.LearnGrid(channel, origin, cfg.Synthetic.GridSpec())
|
||||
if err != nil {
|
||||
t.Fatalf("learn grid: %v", err)
|
||||
}
|
||||
for i, m := range ms {
|
||||
start, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
dur, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
rep, _ := strconv.Atoi(m[3])
|
||||
wantDur, mod := grid.VDur, grid.VMod
|
||||
if i == 1 {
|
||||
wantDur, mod = grid.ADur, grid.AMod
|
||||
}
|
||||
if dur != wantDur {
|
||||
t.Errorf("timeline %d: d=%d, want %d (from the live origin)", i, dur, wantDur)
|
||||
}
|
||||
if int64(rep+1) != cfg.Synthetic.WindowSegments {
|
||||
t.Errorf("timeline %d: %d segments, want %d", i, rep+1, cfg.Synthetic.WindowSegments)
|
||||
}
|
||||
if off := ((start-mod)%wantDur + wantDur) % wantDur; off != 0 {
|
||||
t.Errorf("timeline %d: start %d is off the encoder grid by %d", i, start, off)
|
||||
}
|
||||
if start <= 0 {
|
||||
t.Errorf("timeline %d: non-positive start %d", i, start)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d bytes, vdur=%d adur=%d", channel, len(out), grid.VDur, grid.ADur)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The KID→channel→origin lookup is what makes the expired-session path work.
|
||||
func TestLiveKIDResolvesToConfiguredOrigin(t *testing.T) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Loaded {
|
||||
t.Skipf("no local values at %s", res.Path)
|
||||
}
|
||||
r := NewRewriterWith(cfg)
|
||||
for channel, kid := range cfg.KIDs {
|
||||
gotCh, gotBase, ok := r.ResolveKID(mpd.KIDHex(kid))
|
||||
if !ok {
|
||||
t.Errorf("%s: KID %s did not resolve", channel, kid)
|
||||
continue
|
||||
}
|
||||
if gotCh != channel {
|
||||
t.Errorf("KID %s resolved to %q, want %q", kid, gotCh, channel)
|
||||
}
|
||||
if gotBase != cfg.Origins[channel] {
|
||||
t.Errorf("%s: origin %q, want %q", channel, gotBase, cfg.Origins[channel])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetCaches(channel string) {
|
||||
// The grid/anchor caches live in pkg/mpd and are keyed by channel; a fresh
|
||||
// process per test run is enough, so nothing to do here beyond documenting it.
|
||||
_ = channel
|
||||
}
|
||||
146
apps/modules/rte/mpd.go
Normal file
146
apps/modules/rte/mpd.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package rte
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
func init() {
|
||||
mpd.Register(Name, func() mpd.Rewriter { return NewRewriter() })
|
||||
}
|
||||
|
||||
var reBaseURL = regexp.MustCompile(`(?i)<BaseURL[^>]*>([^<]+)</BaseURL>`)
|
||||
|
||||
// Rewriter turns an ad-stitched upstream MPD into a synthetic manifest on the
|
||||
// origin encoder's own segment grid, so a downloader keeps pulling live segments
|
||||
// after the upstream session expires. All origins and KIDs come from config.
|
||||
type Rewriter struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex
|
||||
fallbackCh string
|
||||
fallbackBase string
|
||||
}
|
||||
|
||||
// NewRewriter loads the module config and returns a rewriter. A config error is
|
||||
// deferred to Rewrite so registry lookup never fails at startup.
|
||||
func NewRewriter() *Rewriter {
|
||||
cfg, _, err := loadConfig()
|
||||
if err != nil {
|
||||
return &Rewriter{cfg: Config{}.withDefaults()}
|
||||
}
|
||||
return &Rewriter{cfg: cfg}
|
||||
}
|
||||
|
||||
// NewRewriterWith builds a rewriter from an already-loaded config.
|
||||
func NewRewriterWith(cfg Config) *Rewriter {
|
||||
return &Rewriter{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Rewriter) Name() string { return Name }
|
||||
|
||||
// Prime records a channel/origin learned elsewhere (e.g. from the KID).
|
||||
func (r *Rewriter) Prime(channel, originBase string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if channel != "" {
|
||||
r.fallbackCh = channel
|
||||
}
|
||||
if originBase != "" {
|
||||
r.fallbackBase = originBase
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveKID maps a KID back to its encoder channel and origin.
|
||||
func (r *Rewriter) ResolveKID(kidHex string) (channel, originBase string, ok bool) {
|
||||
for ch, kid := range r.cfg.KIDs {
|
||||
if mpd.KIDHex(kid) == kidHex {
|
||||
return ch, r.cfg.Origins[ch], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Rewrite produces the synthetic MPD. upstreamXML may be empty (expired session):
|
||||
// the KID fallback then supplies the channel and origin.
|
||||
func (r *Rewriter) Rewrite(upstreamXML []byte, kidHint string) ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
fbCh, fbBase := r.fallbackCh, r.fallbackBase
|
||||
r.mu.Unlock()
|
||||
|
||||
payload, channel, originBase, err := r.rewrite(string(upstreamXML), kidHint, fbCh, fbBase)
|
||||
if channel != "" || originBase != "" {
|
||||
r.Prime(channel, originBase)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func (r *Rewriter) rewrite(xmlText, kidHint, fallbackChannel, fallbackBase string) (payload []byte, channel, originBase string, err error) {
|
||||
cfg := r.cfg
|
||||
channel, originBase = r.originFromXML(xmlText)
|
||||
resolvedKid := mpd.ExtractDefaultKID(xmlText)
|
||||
if resolvedKid == "" && kidHint != "" {
|
||||
resolvedKid, _ = mpd.KIDToUUID(kidHint)
|
||||
}
|
||||
if (channel == "" || originBase == "") && resolvedKid != "" {
|
||||
if mapped, base, ok := r.ResolveKID(mpd.KIDHex(resolvedKid)); ok {
|
||||
if channel == "" {
|
||||
channel = mapped
|
||||
}
|
||||
if originBase == "" {
|
||||
originBase = base
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel == "" {
|
||||
channel = fallbackChannel
|
||||
}
|
||||
if originBase == "" {
|
||||
originBase = fallbackBase
|
||||
}
|
||||
if channel != "" && originBase == "" {
|
||||
originBase = cfg.Origins[channel]
|
||||
}
|
||||
if channel != "" && originBase != "" && r.usesEncoderTimeline(channel) {
|
||||
if resolvedKid == "" {
|
||||
resolvedKid = cfg.KIDs[channel]
|
||||
}
|
||||
if resolvedKid == "" {
|
||||
return nil, "", "", fmt.Errorf("%s: no KID for synthetic MPD channel %s", Name, channel)
|
||||
}
|
||||
payload, err = mpd.BuildSynthetic(cfg.Synthetic, channel, originBase, resolvedKid)
|
||||
return payload, channel, originBase, err
|
||||
}
|
||||
return nil, channel, originBase, fmt.Errorf("%s rewrite: unsupported MPD (no encoder channel); channel=%q", Name, channel)
|
||||
}
|
||||
|
||||
func (r *Rewriter) usesEncoderTimeline(channel string) bool {
|
||||
ok, _ := regexp.MatchString(r.cfg.EncoderChannelRE, channel)
|
||||
return ok
|
||||
}
|
||||
|
||||
// originFromXML finds the origin BaseURL and derives the encoder channel from it.
|
||||
func (r *Rewriter) originFromXML(xmlText string) (channel, base string) {
|
||||
marker := r.cfg.OriginMarker
|
||||
if marker == "" {
|
||||
marker = ".isml"
|
||||
}
|
||||
for _, m := range reBaseURL.FindAllStringSubmatch(xmlText, -1) {
|
||||
text := strings.TrimSpace(m[1])
|
||||
idx := strings.Index(text, marker)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
base = text[:idx] + marker + "/"
|
||||
parts := strings.Split(strings.TrimSuffix(base, "/"), "/")
|
||||
channel = strings.TrimSuffix(parts[len(parts)-1], marker)
|
||||
if channel != "" {
|
||||
return channel, base
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
118
apps/modules/rte/rte.go
Normal file
118
apps/modules/rte/rte.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Package rte is the RTE Player app module: DASH + ModularDrm Widevine, captured
|
||||
// through the phone MITM. Launch/auto-play are Go; every value (package name,
|
||||
// license URL, channel chips, origins, KIDs) comes from the local, untracked
|
||||
// apps/modules/rte/module.yaml, a flag, or the environment.
|
||||
package rte
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/mpd"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
appreg.Register(Name, New)
|
||||
appreg.RegisterFlags(bindFlags)
|
||||
}
|
||||
|
||||
func bindFlags(fs *flag.FlagSet) {
|
||||
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||
fs.StringVar(&flags.licenseURL, Name+".license-url", "", "override "+Name+" Widevine license URL")
|
||||
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||
}
|
||||
|
||||
// App is the RTE app plugin.
|
||||
type App struct {
|
||||
*provider.Base
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New constructs the plugin, loading values from module.yaml + flags + env.
|
||||
func New() (appreg.App, error) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := make(map[string]string, len(cfg.Channels))
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// KeyMode: RTE serves a thePlatform ModularDrm JSON license.
|
||||
func (a *App) KeyMode() string { return "modulardrm" }
|
||||
|
||||
// CaptureHints: the MITM must yield the auth token, program id, PSSH and manifest.
|
||||
func (a *App) CaptureHints() capture.Hints {
|
||||
return a.Hints("auth", "pid", "pssh", "mpd")
|
||||
}
|
||||
|
||||
// MPDRewriter publishes the synthetic encoder-timeline manifest.
|
||||
func (a *App) MPDRewriter() mpd.Rewriter { return NewRewriterWith(a.cfg) }
|
||||
|
||||
// Channels lists the configured live channels.
|
||||
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
|
||||
}
|
||||
|
||||
// Launch cold-starts the app and waits for its home UI.
|
||||
func (a *App) Launch(c *adb.Client) error {
|
||||
c.EnsureAwake()
|
||||
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||
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{
|
||||
DescContains: a.cfg.UI.LaunchDescContains,
|
||||
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI))
|
||||
}
|
||||
|
||||
// AutoPlay navigates to a live channel and waits for playback.
|
||||
func (a *App) AutoPlay(c *adb.Client, channel string) error {
|
||||
id, err := a.Resolve(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch := a.cfg.Channels[id]
|
||||
fmt.Printf("[*] Auto-play %s…\n", a.Label(id))
|
||||
c.DismissShadeIfFocused()
|
||||
|
||||
if err := uiflow.TapUI(c, a.CacheDir(), uiflow.Match{
|
||||
DescRE: a.cfg.UI.LiveTabDescRE,
|
||||
}, time.Duration(a.cfg.Timeouts.LiveTab)); err != nil {
|
||||
return fmt.Errorf("live tab: %w", err)
|
||||
}
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTab))
|
||||
|
||||
if err := a.tapChipThenPlay(c, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||
})
|
||||
}
|
||||
|
||||
// StreamDefaults — the synthetic MPD carries a single 1080p + AAC pair, so a
|
||||
// resolution filter is right here, and the rewriter must run.
|
||||
func (a *App) VideoSelect() string { return "res=1280x720:for=best" }
|
||||
func (a *App) AudioSelect() string { return "lang=en:for=best" }
|
||||
func (a *App) RewriterName() string { return Name }
|
||||
func (a *App) LiveWaitSeconds() int { return 2 }
|
||||
func (a *App) TSReadyBytes() int64 { return 256 << 10 }
|
||||
84
apps/modules/rte/rte_test.go
Normal file
84
apps/modules/rte/rte_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package rte
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
// Compile-time proof the plugin satisfies every interface the host expects.
|
||||
var (
|
||||
_ appreg.App = (*App)(nil)
|
||||
_ appreg.StreamDefaults = (*App)(nil)
|
||||
_ mpd.Rewriter = (*Rewriter)(nil)
|
||||
_ mpd.KIDResolver = (*Rewriter)(nil)
|
||||
_ mpd.Primer = (*Rewriter)(nil)
|
||||
)
|
||||
|
||||
func testConfig() Config {
|
||||
return Config{
|
||||
Channels: map[string]Channel{
|
||||
"chanone": {Label: "Chan One", ChipDesc: []string{"^chanone$"}},
|
||||
"chantwo": {Label: "Chan Two"},
|
||||
},
|
||||
Origins: map[string]string{
|
||||
"vc11": "https://origin.test/live/tc-1/vc11/vc11.isml/",
|
||||
"vc12": "https://origin.test/live/tc-1/vc12/vc12.isml/",
|
||||
},
|
||||
KIDs: map[string]string{
|
||||
"vc11": "df163382-1ddd-fdd5-bec9-822c1ec0f052",
|
||||
},
|
||||
}.withDefaults()
|
||||
}
|
||||
|
||||
func TestResolveKIDMapsBackToOrigin(t *testing.T) {
|
||||
r := NewRewriterWith(testConfig())
|
||||
ch, base, ok := r.ResolveKID("df1633821dddfdd5bec9822c1ec0f052")
|
||||
if !ok {
|
||||
t.Fatal("KID should resolve to a channel")
|
||||
}
|
||||
if ch != "vc11" {
|
||||
t.Errorf("channel = %q, want vc11", ch)
|
||||
}
|
||||
if !strings.Contains(base, "vc11.isml") {
|
||||
t.Errorf("origin base = %q", base)
|
||||
}
|
||||
if _, _, ok := r.ResolveKID("00000000000000000000000000000000"); ok {
|
||||
t.Error("unknown KID must not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginFromXMLUsesConfiguredMarker(t *testing.T) {
|
||||
r := NewRewriterWith(testConfig())
|
||||
xml := `<MPD><BaseURL>https://origin.test/live/tc-1/vc12/vc12.isml/dash/</BaseURL></MPD>`
|
||||
ch, base := r.originFromXML(xml)
|
||||
if ch != "vc12" {
|
||||
t.Errorf("channel = %q, want vc12", ch)
|
||||
}
|
||||
if base != "https://origin.test/live/tc-1/vc12/vc12.isml/" {
|
||||
t.Errorf("base = %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
// With no channel identifiable at all, the rewrite must fail rather than emit a
|
||||
// manifest pointing nowhere.
|
||||
func TestRewriteWithoutChannelFails(t *testing.T) {
|
||||
r := NewRewriterWith(testConfig())
|
||||
if _, err := r.Rewrite([]byte(`<MPD/>`), ""); err == nil {
|
||||
t.Fatal("expected an error with no channel and no KID")
|
||||
}
|
||||
}
|
||||
|
||||
// No provider identity may be compiled in: an empty config must yield no origins
|
||||
// and no KIDs, so a fresh clone cannot stream without its local values file.
|
||||
func TestNoHardcodedOrigins(t *testing.T) {
|
||||
cfg := Config{}.withDefaults()
|
||||
if len(cfg.Origins) != 0 || len(cfg.KIDs) != 0 {
|
||||
t.Fatal("origins/KIDs must come from module.yaml, not from code")
|
||||
}
|
||||
if cfg.Package != "" || cfg.LicenseURL != "" {
|
||||
t.Fatal("package/license URL must not be defaulted in code")
|
||||
}
|
||||
}
|
||||
61
apps/modules/rte/steps.go
Normal file
61
apps/modules/rte/steps.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package rte
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
// tapChipThenPlay is this app's navigation idiom: on the Live tab, tap the
|
||||
// channel chip, then the large hero Play button that appears. If the hero button
|
||||
// is already on screen, tap it directly.
|
||||
func (a *App) tapChipThenPlay(c *adb.Client, ch Channel) error {
|
||||
playPats := uiflow.CompileRes(ch.PlayDesc)
|
||||
chipPats := uiflow.CompileRes(ch.ChipDesc)
|
||||
|
||||
nodes, err := c.DumpUI(a.CacheDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cand := a.findHeroPlay(nodes, playPats); cand != nil {
|
||||
fmt.Printf("[*] tap Play @ %d,%d\n", cand.CX(), cand.CY())
|
||||
return c.Tap(cand.CX(), cand.CY())
|
||||
}
|
||||
|
||||
chip := adb.FindSmallest(nodes, chipPats)
|
||||
if chip == nil {
|
||||
chip, err = c.WaitFor(a.CacheDir(), nil, chipPats, time.Duration(a.cfg.Timeouts.ChipWait))
|
||||
if err != nil {
|
||||
return fmt.Errorf("chip not found: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[*] tap chip @ %d,%d\n", chip.CX(), chip.CY())
|
||||
_ = c.Tap(chip.CX(), chip.CY())
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterChip))
|
||||
|
||||
deadline := time.Now().Add(time.Duration(a.cfg.Timeouts.PlayWait))
|
||||
for time.Now().Before(deadline) {
|
||||
nodes, err = c.DumpUI(a.CacheDir())
|
||||
if err == nil {
|
||||
if cand := a.findHeroPlay(nodes, playPats); cand != nil {
|
||||
fmt.Printf("[*] tap Play @ %d,%d\n", cand.CX(), cand.CY())
|
||||
return c.Tap(cand.CX(), cand.CY())
|
||||
}
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Play button did not appear")
|
||||
}
|
||||
|
||||
// findHeroPlay accepts a Play match only if it is big enough to be the hero
|
||||
// button rather than a small list-item play icon.
|
||||
func (a *App) findHeroPlay(nodes []adb.Node, playPats []*regexp.Regexp) *adb.Node {
|
||||
cand := adb.FindLargest(nodes, nil, playPats)
|
||||
if cand == nil || cand.Area() < a.cfg.HeroMinArea {
|
||||
return nil
|
||||
}
|
||||
return cand
|
||||
}
|
||||
151
apps/modules/tg4/catalog.go
Normal file
151
apps/modules/tg4/catalog.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package tg4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/brightcove"
|
||||
"drmdecryption/session"
|
||||
)
|
||||
|
||||
// playbackConfig is the app's remote config blob. It is decoded generically: the
|
||||
// field names a channel uses come from module.yaml (token_field / stream_id_field),
|
||||
// so a renamed or added field needs no code change.
|
||||
type playbackConfig map[string]any
|
||||
|
||||
func fetchPlaybackConfig(url string) (playbackConfig, error) {
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("playback_config_url required (set it in module.yaml or --%s.playback-config-url)", Name)
|
||||
}
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("playback config HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var pc playbackConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// str reads a string field, tolerating numbers.
|
||||
func (pc playbackConfig) str(field string) string {
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
switch v := pc[field].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Flags reports the boolean switches in the playback config, for `--list`.
|
||||
func (pc playbackConfig) Flags() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for k, v := range pc {
|
||||
if b, ok := v.(bool); ok {
|
||||
out[k] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveChannel returns the channel's label, Brightcove video id and playback
|
||||
// token, consulting the remote playback config for anything not pinned locally.
|
||||
func (a *App) resolveChannel(pc playbackConfig, id string) (label, videoID, token string, err error) {
|
||||
ch, ok := a.cfg.Channels[id]
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("unknown channel %q", id)
|
||||
}
|
||||
label = a.Label(id)
|
||||
videoID = ch.VideoID
|
||||
if videoID == "" {
|
||||
videoID = pc.str(ch.StreamIDField)
|
||||
}
|
||||
token = pc.str(ch.TokenField)
|
||||
|
||||
if videoID == "" {
|
||||
if ch.PhoneOnly {
|
||||
return "", "", "", fmt.Errorf("channel %q has no catalog entry — capture it from the phone: drm capture --app %s --channel %s", id, Name, id)
|
||||
}
|
||||
return "", "", "", fmt.Errorf("no video id for channel %q (set video_id or stream_id_field in %s)", id, a.ConfigPath())
|
||||
}
|
||||
if token == "" && ch.TokenField != "" {
|
||||
return label, videoID, "", fmt.Errorf("no playback token for channel %q (field %s)", id, ch.TokenField)
|
||||
}
|
||||
return label, videoID, token, nil
|
||||
}
|
||||
|
||||
// Resolve implements app.Catalog: build streamd-ready credentials for a channel
|
||||
// without touching a phone.
|
||||
func (a *App) Resolve(channel string) (session.Stream, error) {
|
||||
id, err := a.Base.Resolve(channel)
|
||||
if err != nil {
|
||||
return session.Stream{}, err
|
||||
}
|
||||
pc, err := fetchPlaybackConfig(a.cfg.PlaybackConfigURL)
|
||||
if err != nil {
|
||||
return session.Stream{}, err
|
||||
}
|
||||
label, videoID, token, err := a.resolveChannel(pc, id)
|
||||
if err != nil {
|
||||
return session.Stream{}, err
|
||||
}
|
||||
vid, err := brightcove.FetchPlayback(a.cfg.AccountID, videoID, token, a.cfg.PolicyKey)
|
||||
if err != nil {
|
||||
return session.Stream{}, err
|
||||
}
|
||||
hls, license := brightcove.PickHLSAndLicense(vid)
|
||||
if hls == "" {
|
||||
return session.Stream{}, fmt.Errorf("no HLS source in playback response for %s", videoID)
|
||||
}
|
||||
title := label
|
||||
if vid.Name != "" {
|
||||
title = vid.Name
|
||||
}
|
||||
return session.Stream{
|
||||
Name: a.cfg.StreamNamePrefix + "-" + strings.ToLower(id),
|
||||
Title: title,
|
||||
App: Name,
|
||||
Channel: strings.ToLower(id),
|
||||
MPD: hls,
|
||||
HeadersJSON: "{}",
|
||||
Rewriter: "none",
|
||||
LicenseURL: license,
|
||||
AccountID: a.cfg.AccountID,
|
||||
VideoID: videoID,
|
||||
PlaybackURL: brightcove.PlaybackURL(a.cfg.AccountID, videoID),
|
||||
ManifestType: "hls",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListChannels resolves each configured channel's video id for `catalog --list`.
|
||||
func (a *App) ListChannels() ([]appreg.CatalogRow, map[string]bool, error) {
|
||||
pc, err := fetchPlaybackConfig(a.cfg.PlaybackConfigURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows := make([]appreg.CatalogRow, 0, len(a.cfg.Channels))
|
||||
for _, id := range a.ChannelIDs() {
|
||||
ch := a.cfg.Channels[id]
|
||||
vid := ch.VideoID
|
||||
if vid == "" {
|
||||
vid = pc.str(ch.StreamIDField)
|
||||
}
|
||||
rows = append(rows, appreg.CatalogRow{ID: id, CatalogID: vid, Label: a.Label(id)})
|
||||
}
|
||||
return rows, pc.Flags(), nil
|
||||
}
|
||||
172
apps/modules/tg4/config.go
Normal file
172
apps/modules/tg4/config.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package tg4
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
// Name is the module id (--app tg4).
|
||||
const Name = "tg4"
|
||||
|
||||
// Channel is one live channel. VideoID may be empty when the id must be read from
|
||||
// the remote playback config instead.
|
||||
type Channel struct {
|
||||
Label string `yaml:"label"`
|
||||
// VideoID is the Brightcove video id for this live.
|
||||
VideoID string `yaml:"video_id"`
|
||||
// TokenField names the playback-config field holding this live's playback token.
|
||||
TokenField string `yaml:"token_field"`
|
||||
// StreamIDField names the playback-config field holding this live's video id,
|
||||
// used when VideoID is empty.
|
||||
StreamIDField string `yaml:"stream_id_field"`
|
||||
// LiveCardIndex is this channel's position in the app's Live card list.
|
||||
LiveCardIndex int `yaml:"live_card_index"`
|
||||
// PhoneOnly marks a channel with no catalog entry — capture via the phone UI.
|
||||
PhoneOnly bool `yaml:"phone_only"`
|
||||
}
|
||||
|
||||
// Config is apps/modules/tg4/module.yaml.
|
||||
type Config struct {
|
||||
provider.Common `yaml:",inline"`
|
||||
|
||||
// Brightcove account credentials. Secrets: never defaulted in code.
|
||||
AccountID string `yaml:"account_id"`
|
||||
PolicyKey string `yaml:"policy_key"`
|
||||
PlaybackConfigURL string `yaml:"playback_config_url"`
|
||||
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
|
||||
// StreamNamePrefix prefixes generated stream names (default: module name).
|
||||
StreamNamePrefix string `yaml:"stream_name_prefix"`
|
||||
|
||||
Cards uiflow.CardOpts `yaml:"cards"`
|
||||
UI UI `yaml:"ui"`
|
||||
Timeouts Timeouts `yaml:"timeouts"`
|
||||
}
|
||||
|
||||
// UI holds the markers and fallbacks the Live-page navigation uses.
|
||||
type UI struct {
|
||||
// LaunchDescContains: any of these content-descriptions means the app is up.
|
||||
LaunchDescContains []string `yaml:"launch_desc_contains"`
|
||||
// LaunchResourceContains: any of these view ids means the app is up. More
|
||||
// stable than descriptions, which are localized.
|
||||
LaunchResourceContains []string `yaml:"launch_resource_contains"`
|
||||
// LiveLabel is the menu/title text identifying the Live page.
|
||||
LiveLabel string `yaml:"live_label"`
|
||||
// LiveTitleResource marks the Live page title node.
|
||||
LiveTitleResource string `yaml:"live_title_resource"`
|
||||
// DrawerDesc are the content-descriptions of the drawer (hamburger) button.
|
||||
DrawerDesc []string `yaml:"drawer_desc"`
|
||||
// DrawerFallbackX/Y is tapped when the drawer button cannot be identified.
|
||||
DrawerFallbackX int `yaml:"drawer_fallback_x"`
|
||||
DrawerFallbackY int `yaml:"drawer_fallback_y"`
|
||||
// MenuMaxX / MenuMinY / MenuMaxY bound where a drawer menu entry can sit.
|
||||
MenuMaxX int `yaml:"menu_max_x"`
|
||||
MenuMinY int `yaml:"menu_min_y"`
|
||||
MenuMaxY int `yaml:"menu_max_y"`
|
||||
// TitleMaxY bounds where the Live page title can sit.
|
||||
TitleMaxY int `yaml:"title_max_y"`
|
||||
// PlaybackDescContains / PlaybackResourceContains are player-up signals.
|
||||
PlaybackDescContains []string `yaml:"playback_desc_contains"`
|
||||
PlaybackResourceContains []string `yaml:"playback_resource_contains"`
|
||||
}
|
||||
|
||||
// Timeouts for the launch / autoplay sequence. Written as "25s" in module.yaml.
|
||||
type Timeouts struct {
|
||||
LaunchWaitUI provider.Duration `yaml:"launch_wait_ui"`
|
||||
LaunchSettle provider.Duration `yaml:"launch_settle"`
|
||||
LiveNav provider.Duration `yaml:"live_nav"`
|
||||
Card provider.Duration `yaml:"card"`
|
||||
AfterCard provider.Duration `yaml:"after_card"`
|
||||
Playback provider.Duration `yaml:"playback"`
|
||||
AfterLiveTap provider.Duration `yaml:"after_live_tap"`
|
||||
AfterDrawer provider.Duration `yaml:"after_drawer"`
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.StreamNamePrefix == "" {
|
||||
c.StreamNamePrefix = Name
|
||||
}
|
||||
c.Cards = c.Cards.WithDefaults()
|
||||
if len(c.Cards.DescDeny) == 0 {
|
||||
c.Cards.DescDeny = []string{"Search", "Profile", "Cast", "Open", "Closed"}
|
||||
}
|
||||
if len(c.Cards.TextDeny) == 0 {
|
||||
c.Cards.TextDeny = []string{"catch-up"}
|
||||
}
|
||||
u := &c.UI
|
||||
if len(u.LaunchDescContains) == 0 && len(u.LaunchResourceContains) == 0 {
|
||||
u.LaunchDescContains = []string{"live", "home"}
|
||||
}
|
||||
if u.LiveLabel == "" {
|
||||
u.LiveLabel = "live"
|
||||
}
|
||||
if u.LiveTitleResource == "" {
|
||||
u.LiveTitleResource = "live_fragment_text_view"
|
||||
}
|
||||
if len(u.DrawerDesc) == 0 {
|
||||
u.DrawerDesc = []string{"Open", "Closed"}
|
||||
}
|
||||
if u.DrawerFallbackX == 0 {
|
||||
u.DrawerFallbackX = 68
|
||||
}
|
||||
if u.DrawerFallbackY == 0 {
|
||||
u.DrawerFallbackY = 183
|
||||
}
|
||||
if u.MenuMaxX == 0 {
|
||||
u.MenuMaxX = 700
|
||||
}
|
||||
if u.MenuMinY == 0 {
|
||||
u.MenuMinY = 250
|
||||
}
|
||||
if u.MenuMaxY == 0 {
|
||||
u.MenuMaxY = 700
|
||||
}
|
||||
if u.TitleMaxY == 0 {
|
||||
u.TitleMaxY = 400
|
||||
}
|
||||
if len(u.PlaybackDescContains) == 0 {
|
||||
u.PlaybackDescContains = []string{"video player"}
|
||||
}
|
||||
if len(u.PlaybackResourceContains) == 0 {
|
||||
u.PlaybackResourceContains = []string{"brightcove_video_view"}
|
||||
}
|
||||
t := &c.Timeouts
|
||||
t.LaunchWaitUI = provider.Duration(t.LaunchWaitUI.D(30 * time.Second))
|
||||
t.LaunchSettle = provider.Duration(t.LaunchSettle.D(5 * time.Second))
|
||||
t.LiveNav = provider.Duration(t.LiveNav.D(25 * time.Second))
|
||||
t.Card = provider.Duration(t.Card.D(25 * time.Second))
|
||||
t.AfterCard = provider.Duration(t.AfterCard.D(3 * time.Second))
|
||||
t.Playback = provider.Duration(t.Playback.D(60 * time.Second))
|
||||
t.AfterLiveTap = provider.Duration(t.AfterLiveTap.D(3 * time.Second))
|
||||
t.AfterDrawer = provider.Duration(t.AfterDrawer.D(1200 * time.Millisecond))
|
||||
return c
|
||||
}
|
||||
|
||||
// flag overrides, bound before any app is constructed.
|
||||
var flags struct {
|
||||
packageName string
|
||||
accountID string
|
||||
policyKey string
|
||||
playbackConfigURL string
|
||||
caHash string
|
||||
proxyBin string
|
||||
}
|
||||
|
||||
func loadConfig() (Config, modcfg.Result, error) {
|
||||
var cfg Config
|
||||
res, err := modcfg.Load(Name, &cfg)
|
||||
if err != nil {
|
||||
return cfg, res, err
|
||||
}
|
||||
cfg.Package = modcfg.Override(Name, "package", flags.packageName, cfg.Package)
|
||||
cfg.CAHash = modcfg.Override(Name, "ca_hash", flags.caHash, cfg.CAHash)
|
||||
cfg.ProxyBin = modcfg.Override(Name, "proxy_bin", flags.proxyBin, cfg.ProxyBin)
|
||||
cfg.AccountID = modcfg.Override(Name, "account_id", flags.accountID, cfg.AccountID)
|
||||
cfg.PolicyKey = modcfg.Override(Name, "policy_key", flags.policyKey, cfg.PolicyKey)
|
||||
cfg.PlaybackConfigURL = modcfg.Override(Name, "playback_config_url", flags.playbackConfigURL, cfg.PlaybackConfigURL)
|
||||
return cfg.withDefaults(), res, nil
|
||||
}
|
||||
62
apps/modules/tg4/keys.go
Normal file
62
apps/modules/tg4/keys.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package tg4
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"drmdecryption/brightcove"
|
||||
"drmdecryption/repo"
|
||||
"drmdecryption/session"
|
||||
"drmdecryption/wvkey"
|
||||
)
|
||||
|
||||
// EnrichWithKeys fetches the HLS master, extracts the Widevine PSSH and resolves
|
||||
// every content key through the local CDM. Brightcove returns multiple CONTENT
|
||||
// keys and a downloader needs all of them.
|
||||
func (a *App) EnrichWithKeys(info *session.Stream, python, wvd string) error {
|
||||
if info == nil || info.MPD == "" {
|
||||
return fmt.Errorf("missing manifest URL")
|
||||
}
|
||||
if info.LicenseURL == "" {
|
||||
return fmt.Errorf("missing license_url")
|
||||
}
|
||||
if strings.TrimSpace(wvd) == "" {
|
||||
return fmt.Errorf("key fetch requires --wvd (path to .wvd device file)")
|
||||
}
|
||||
body, err := brightcove.Get(info.MPD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hls master: %w", err)
|
||||
}
|
||||
pssh := brightcove.ExtractWidevinePSSH(body)
|
||||
if pssh == "" {
|
||||
return fmt.Errorf("no Widevine PSSH in HLS master")
|
||||
}
|
||||
info.PSSH = pssh
|
||||
info.PrimaryKID = brightcove.PrimaryKID(body)
|
||||
|
||||
all, err := wvkey.FetchRawAll(wvkey.Options{
|
||||
Python: python,
|
||||
Script: filepath.Join(repo.Root(), "apps", "wvkey", "wvkey.py"),
|
||||
WVD: wvd,
|
||||
PSSH: pssh,
|
||||
LicenseURL: info.LicenseURL,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return fmt.Errorf("license returned no content keys")
|
||||
}
|
||||
info.Keys = all
|
||||
info.Key = all[0]
|
||||
if info.PrimaryKID != "" {
|
||||
for _, k := range all {
|
||||
if strings.HasPrefix(strings.ToLower(k), info.PrimaryKID+":") {
|
||||
info.Key = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
152
apps/modules/tg4/steps.go
Normal file
152
apps/modules/tg4/steps.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package tg4
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
// openLivePage navigates to the Live page: if a menu entry is visible tap it,
|
||||
// otherwise open the side drawer first. Ported from the Starlark hook this module
|
||||
// used to carry, with every selector and coordinate now coming from config.
|
||||
func (a *App) openLivePage(c *adb.Client) error {
|
||||
c.DismissShadeIfFocused()
|
||||
nodes, err := c.DumpUI(a.CacheDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.onLivePage(nodes) {
|
||||
fmt.Println("[*] already on Live")
|
||||
return nil
|
||||
}
|
||||
if live := a.findMenuLive(nodes); live != nil {
|
||||
fmt.Printf("[*] tap Live @ %d,%d\n", live.CX(), live.CY())
|
||||
_ = c.Tap(live.CX(), live.CY())
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTap))
|
||||
return nil
|
||||
}
|
||||
|
||||
if !a.openDrawer(c) {
|
||||
fmt.Printf("[*] drawer fallback tap %d,%d\n", a.cfg.UI.DrawerFallbackX, a.cfg.UI.DrawerFallbackY)
|
||||
_ = c.Tap(a.cfg.UI.DrawerFallbackX, a.cfg.UI.DrawerFallbackY)
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterDrawer))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(a.cfg.Timeouts.LiveNav))
|
||||
for time.Now().Before(deadline) {
|
||||
nodes, err := c.DumpUI(a.CacheDir())
|
||||
if err == nil {
|
||||
if live := a.findMenuLive(nodes); live != nil {
|
||||
fmt.Printf("[*] tap Live @ %d,%d\n", live.CX(), live.CY())
|
||||
_ = c.Tap(live.CX(), live.CY())
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterLiveTap))
|
||||
break
|
||||
}
|
||||
a.openDrawer(c)
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
}
|
||||
|
||||
deadline = time.Now().Add(time.Duration(a.cfg.Timeouts.LiveNav))
|
||||
for time.Now().Before(deadline) {
|
||||
if nodes, err := c.DumpUI(a.CacheDir()); err == nil && a.onLivePage(nodes) {
|
||||
fmt.Printf("[*] Live page ready\n")
|
||||
return nil
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Live page did not open")
|
||||
}
|
||||
|
||||
// onLivePage is true when the Live title is showing and at least one content card
|
||||
// is on screen.
|
||||
func (a *App) onLivePage(nodes []adb.Node) bool {
|
||||
hasTitle := false
|
||||
for _, n := range nodes {
|
||||
if !strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(n.ResourceID, a.cfg.UI.LiveTitleResource) ||
|
||||
(n.Y1 < a.cfg.UI.TitleMaxY && !n.Clickable) {
|
||||
hasTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTitle {
|
||||
return false
|
||||
}
|
||||
return len(uiflow.Cards(nodes, a.cfg.Cards)) > 0
|
||||
}
|
||||
|
||||
// openDrawer opens the side menu. Returns false when the drawer button could not
|
||||
// be identified, so the caller can fall back to a fixed tap.
|
||||
func (a *App) openDrawer(c *adb.Client) bool {
|
||||
nodes, err := c.DumpUI(a.CacheDir())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Already open?
|
||||
for _, n := range nodes {
|
||||
desc := strings.TrimSpace(n.Desc)
|
||||
for _, d := range a.cfg.UI.DrawerDesc {
|
||||
if desc == d && d == "Closed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) &&
|
||||
n.Clickable && n.X2 < a.cfg.UI.MenuMaxX {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ham := a.findDrawerButton(nodes)
|
||||
if ham == nil {
|
||||
return false
|
||||
}
|
||||
fmt.Printf("[*] tap menu %q @ %d,%d\n", ham.Desc, ham.CX(), ham.CY())
|
||||
_ = c.Tap(ham.CX(), ham.CY())
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterDrawer))
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) findDrawerButton(nodes []adb.Node) *adb.Node {
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if !n.Clickable {
|
||||
continue
|
||||
}
|
||||
desc := strings.TrimSpace(n.Desc)
|
||||
for _, d := range a.cfg.UI.DrawerDesc {
|
||||
if desc == d {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
// Geometric fallback: a small clickable node in the top-left corner.
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.Clickable && n.X1 < 50 && n.Y1 < 200 && n.X2 < 200 && n.Y2 < 300 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findMenuLive locates the drawer's Live entry.
|
||||
func (a *App) findMenuLive(nodes []adb.Node) *adb.Node {
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if !n.Clickable {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(n.Text), a.cfg.UI.LiveLabel) {
|
||||
continue
|
||||
}
|
||||
if n.X2 <= a.cfg.UI.MenuMaxX && n.Y1 > a.cfg.UI.MenuMinY && n.Y1 < a.cfg.UI.MenuMaxY {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
125
apps/modules/tg4/tg4.go
Normal file
125
apps/modules/tg4/tg4.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Package tg4 is the TG4 app module: Brightcove HLS with a raw Widevine license.
|
||||
// Its channels can be resolved from the public catalog (no phone), or captured
|
||||
// through the phone MITM. Account id, policy key, video ids and the playback
|
||||
// config URL all come from the local, untracked apps/modules/tg4/module.yaml, a
|
||||
// flag, or the environment — never from this source.
|
||||
package tg4
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"drmdecryption/adb"
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/modules/provider"
|
||||
"drmdecryption/mpd"
|
||||
"drmdecryption/uiflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
appreg.Register(Name, New)
|
||||
appreg.RegisterFlags(bindFlags)
|
||||
}
|
||||
|
||||
func bindFlags(fs *flag.FlagSet) {
|
||||
fs.StringVar(&flags.packageName, Name+".package", "", "override "+Name+" android package id")
|
||||
fs.StringVar(&flags.accountID, Name+".account-id", "", "override "+Name+" Brightcove account id")
|
||||
fs.StringVar(&flags.policyKey, Name+".policy-key", "", "override "+Name+" Brightcove policy key")
|
||||
fs.StringVar(&flags.playbackConfigURL, Name+".playback-config-url", "", "override "+Name+" playback config URL")
|
||||
fs.StringVar(&flags.caHash, Name+".ca-hash", "", "override "+Name+" MITM CA subject hash")
|
||||
fs.StringVar(&flags.proxyBin, Name+".proxy-bin", "", "override "+Name+" on-device MITM binary")
|
||||
}
|
||||
|
||||
// App is the TG4 app plugin.
|
||||
type App struct {
|
||||
*provider.Base
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New constructs the plugin, loading values from module.yaml + flags + env.
|
||||
func New() (appreg.App, error) {
|
||||
cfg, res, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels := make(map[string]string, len(cfg.Channels))
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, res), cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// KeyMode: Brightcove serves a raw octet-stream Widevine license.
|
||||
func (a *App) KeyMode() string { return "raw" }
|
||||
|
||||
// CaptureHints: the MITM yields the license URL and HLS master; the PSSH is read
|
||||
// from the master afterwards.
|
||||
func (a *App) CaptureHints() capture.Hints {
|
||||
return a.Hints("license_url", "mpd")
|
||||
}
|
||||
|
||||
// MPDRewriter: HLS needs no manifest rewrite.
|
||||
func (a *App) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||
|
||||
// Channels lists the configured live channels.
|
||||
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
|
||||
}
|
||||
|
||||
// Launch cold-starts the app and waits for its home UI.
|
||||
func (a *App) Launch(c *adb.Client) error {
|
||||
c.EnsureAwake()
|
||||
fmt.Printf("[*] Launching %s (%s)…\n", Name, a.Package())
|
||||
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{
|
||||
DescContains: a.cfg.UI.LaunchDescContains,
|
||||
ResourceContains: a.cfg.UI.LaunchResourceContains,
|
||||
}, time.Duration(a.cfg.Timeouts.LaunchWaitUI))
|
||||
}
|
||||
|
||||
// AutoPlay opens the Live page and taps this channel's card.
|
||||
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]
|
||||
fmt.Printf("[*] Auto-play %s…\n", a.Label(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
|
||||
}
|
||||
time.Sleep(time.Duration(a.cfg.Timeouts.AfterCard))
|
||||
|
||||
// Soft-fail: this player often starts without a dumpsys audio session, so a
|
||||
// UI signal is the real confirmation and capture should continue regardless.
|
||||
return uiflow.WaitPlayback(c, a.CacheDir(), a.Package(), uiflow.PlaybackOpts{
|
||||
Timeout: time.Duration(a.cfg.Timeouts.Playback),
|
||||
SoftFail: true,
|
||||
OrDescContains: a.cfg.UI.PlaybackDescContains,
|
||||
OrResourceContains: a.cfg.UI.PlaybackResourceContains,
|
||||
})
|
||||
}
|
||||
|
||||
// StreamDefaults — multi-KID SAMPLE-AES HLS: take the best variant and give the
|
||||
// muxer more bytes before probing, since early audio config is often incomplete.
|
||||
func (a *App) VideoSelect() string { return "for=best" }
|
||||
func (a *App) AudioSelect() string { return "for=best" }
|
||||
func (a *App) RewriterName() string { return "none" }
|
||||
func (a *App) LiveWaitSeconds() int { return 6 }
|
||||
func (a *App) TSReadyBytes() int64 { return 2 << 20 }
|
||||
129
apps/modules/tg4/tg4_test.go
Normal file
129
apps/modules/tg4/tg4_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package tg4
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
appreg "drmdecryption/app"
|
||||
"drmdecryption/modcfg"
|
||||
"drmdecryption/modules/provider"
|
||||
)
|
||||
|
||||
// Compile-time proof the plugin satisfies every interface the host expects.
|
||||
var (
|
||||
_ appreg.App = (*App)(nil)
|
||||
_ appreg.Catalog = (*App)(nil)
|
||||
_ appreg.StreamDefaults = (*App)(nil)
|
||||
)
|
||||
|
||||
func testApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
cfg := Config{
|
||||
AccountID: "acct",
|
||||
PolicyKey: "pk",
|
||||
PlaybackConfigURL: "https://cfg.test/playback.json",
|
||||
Channels: map[string]Channel{
|
||||
"main": {Label: "Main", VideoID: "111", TokenField: "mainToken", LiveCardIndex: 0},
|
||||
"extra": {Label: "Extra", StreamIDField: "extraStreamId", TokenField: "extraToken", LiveCardIndex: 1},
|
||||
"phone": {Label: "Phone only", PhoneOnly: true, LiveCardIndex: 3},
|
||||
},
|
||||
Common: provider.Common{
|
||||
Aliases: map[string]string{"primary": "main", "second": "extra"},
|
||||
},
|
||||
}.withDefaults()
|
||||
labels := map[string]string{}
|
||||
for id, ch := range cfg.Channels {
|
||||
labels[id] = ch.Label
|
||||
}
|
||||
return &App{Base: provider.New(Name, cfg.Common, labels, modcfg.Result{Path: "test"}), cfg: cfg}
|
||||
}
|
||||
|
||||
func TestAliasesResolveFromConfig(t *testing.T) {
|
||||
a := testApp(t)
|
||||
for in, want := range map[string]string{
|
||||
"main": "main", "MAIN": "main", "primary": "main",
|
||||
"second": "extra", "extra": "extra",
|
||||
} {
|
||||
got, err := a.Base.Resolve(in)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %q: %v", in, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := a.Base.Resolve("nope"); err == nil {
|
||||
t.Error("unknown channel must error")
|
||||
}
|
||||
}
|
||||
|
||||
// A video id pinned in config wins; otherwise it is read from the named remote
|
||||
// playback-config field. Both paths are data-driven, no channel name switch.
|
||||
func TestResolveChannelReadsConfiguredFields(t *testing.T) {
|
||||
a := testApp(t)
|
||||
pc := playbackConfig{
|
||||
"mainToken": "tok-main",
|
||||
"extraToken": "tok-extra",
|
||||
"extraStreamId": "222",
|
||||
}
|
||||
_, vid, tok, err := a.resolveChannel(pc, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("main: %v", err)
|
||||
}
|
||||
if vid != "111" || tok != "tok-main" {
|
||||
t.Errorf("main → video %q token %q", vid, tok)
|
||||
}
|
||||
_, vid, tok, err = a.resolveChannel(pc, "extra")
|
||||
if err != nil {
|
||||
t.Fatalf("extra: %v", err)
|
||||
}
|
||||
if vid != "222" || tok != "tok-extra" {
|
||||
t.Errorf("extra → video %q token %q", vid, tok)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone-only channel must say so rather than fail with a confusing catalog error.
|
||||
func TestPhoneOnlyChannelExplainsItself(t *testing.T) {
|
||||
a := testApp(t)
|
||||
_, _, _, err := a.resolveChannel(playbackConfig{}, "phone")
|
||||
if err == nil {
|
||||
t.Fatal("phone-only channel must not resolve from the catalog")
|
||||
}
|
||||
if want := "drm capture"; !contains(err.Error(), want) {
|
||||
t.Errorf("error %q should point at %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackConfigCoercesNumericIDs(t *testing.T) {
|
||||
pc := playbackConfig{"a": "123", "b": float64(456), "c": true}
|
||||
if pc.str("a") != "123" {
|
||||
t.Error("string field")
|
||||
}
|
||||
if pc.str("b") != "456" {
|
||||
t.Errorf("numeric field = %q, want 456", pc.str("b"))
|
||||
}
|
||||
if pc.str("missing") != "" || pc.str("") != "" {
|
||||
t.Error("missing field must be empty")
|
||||
}
|
||||
if !pc.Flags()["c"] {
|
||||
t.Error("bool flags should be reported")
|
||||
}
|
||||
}
|
||||
|
||||
// No provider identity may be compiled in.
|
||||
func TestNoHardcodedCredentials(t *testing.T) {
|
||||
cfg := Config{}.withDefaults()
|
||||
if cfg.AccountID != "" || cfg.PolicyKey != "" || cfg.PlaybackConfigURL != "" || cfg.Package != "" {
|
||||
t.Fatal("account id / policy key / config URL / package must come from module.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue