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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue