// Package brightcove talks to the Brightcove Playback API and reads Widevine // material out of an HLS master. Brightcove is a platform, not a provider: this // package holds no account, policy key, video id or channel knowledge. package brightcove import ( "encoding/json" "fmt" "io" "net/http" "regexp" "strings" ) // EdgeAPI is the Playback API base. Overridable for tests or a proxy. var EdgeAPI = "https://edge.api.brightcove.com/playback/v1" var ( // SESSION-KEY / KEY lines put URI and KEYFORMAT in either order. reWidevinePSSH = regexp.MustCompile(`(?is)KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed".*?URI="data:text/plain;base64,([A-Za-z0-9+/=]+)"`) reWidevinePSSH2 = regexp.MustCompile(`(?is)URI="data:text/plain;base64,([A-Za-z0-9+/=]+)".*?KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"`) reKeyID = regexp.MustCompile(`(?i)keyId=([0-9a-f]{32})`) ) // WidevineKeySystem is the key_systems map key for Widevine sources. const WidevineKeySystem = "com.widevine.alpha" // Video is the subset of a Playback API response we use. type Video struct { ID string `json:"id"` Name string `json:"name"` Sources []struct { Type string `json:"type"` Src string `json:"src"` KeySystems map[string]struct { LicenseURL string `json:"license_url"` } `json:"key_systems"` } `json:"sources"` } // PlaybackURL is the canonical (token-free) Playback API URL for a video. func PlaybackURL(accountID, videoID string) string { return fmt.Sprintf("%s/accounts/%s/videos/%s", EdgeAPI, accountID, videoID) } // FetchPlayback resolves a video through the Playback API. livePlaybackToken may // be empty for assets that do not need one. func FetchPlayback(accountID, videoID, livePlaybackToken, policyKey string) (Video, error) { var v Video if policyKey == "" { return v, fmt.Errorf("policy key required") } if accountID == "" { return v, fmt.Errorf("account id required") } u := PlaybackURL(accountID, videoID) if livePlaybackToken != "" { u += "?livePlaybackToken=" + livePlaybackToken } req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return v, err } req.Header.Set("Accept", "application/json;pk="+policyKey) resp, err := http.DefaultClient.Do(req) if err != nil { return v, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { return v, fmt.Errorf("brightcove playback HTTP %d: %s", resp.StatusCode, Trim(string(body), 200)) } if err := json.Unmarshal(body, &v); err != nil { return v, err } return v, nil } // PickHLSAndLicense chooses the best HLS source: prefer a DVR playlist that // carries a Widevine license URL, then any Widevine source, then any DVR // playlist, then any HLS at all. func PickHLSAndLicense(v Video) (hls, license string) { var dvr, dvrWV, anyWV, anyHLS string var dvrLic, anyLic string for _, s := range v.Sources { if !strings.Contains(strings.ToLower(s.Type), "mpegurl") && !strings.Contains(strings.ToLower(s.Src), ".m3u8") { continue } src := s.Src lic := "" if ks, ok := s.KeySystems[WidevineKeySystem]; ok { lic = ks.LicenseURL } isDVR := strings.Contains(src, "playlist-hls-dvr.m3u8") || strings.Contains(src, "-dvr") switch { case isDVR && lic != "" && dvrWV == "": dvrWV, dvrLic = src, lic case isDVR && dvr == "": dvr = src case lic != "" && anyWV == "": anyWV, anyLic = src, lic case anyHLS == "": anyHLS = src } } switch { case dvrWV != "": return dvrWV, dvrLic case anyWV != "": return anyWV, anyLic case dvr != "": return dvr, "" default: return anyHLS, "" } } // ExtractWidevinePSSH returns the base64 Widevine init data from an HLS master. func ExtractWidevinePSSH(master string) string { if m := reWidevinePSSH.FindStringSubmatch(master); len(m) == 2 { return m[1] } if m := reWidevinePSSH2.FindStringSubmatch(master); len(m) == 2 { return m[1] } return "" } // PrimaryKID returns the first keyId= seen in an HLS master, lowercased. func PrimaryKID(master string) string { if kids := reKeyID.FindAllStringSubmatch(master, -1); len(kids) > 0 { return strings.ToLower(kids[0][1]) } return "" } // Get fetches a URL as text (used for the HLS master). func Get(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } b, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(b), nil } // Trim shortens a string for error messages. func Trim(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }