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 }