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:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

151
apps/modules/tg4/catalog.go Normal file
View 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
View 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
View 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
View 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
View 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 }

View 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
})()
}