initial commit

This commit is contained in:
Christopher Tarry 2021-11-20 13:53:30 -05:00
commit fef9153a12
12 changed files with 8144 additions and 0 deletions

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright © 2021 Christopher Tarry
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# Hulu Downloader
The code in this repository allows you to download videos unencumbered with DRM from Hulu. The code in `widevine` is in general independent of the Hulu related code and can be used for Widevine license generation/decryption. The code in `hulu` is also standalone but only implements a handful of endpoints that are basically only useful for a command line tool of this nature.
## Prerequisites
The code in this repository by itself does not require any external libraries or tools to be installed. It merely finds the video URLs and decryption keys. The only dependencies required are cryptographic libraraies specified in go.mod but Go should handle these automatically. However, to actually perform MP4 decryption, Bento4 (and specifically its `mp4decrypt` tool) are required. Bento4 is an open source library for MP4 manipulation. Binary releases of its tools can be downloaded [here](https://www.bento4.com/downloads/). [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) is also required to download the MPD playlist files to mp4s. Technically, this could be implemented rather easily in this repository but I want to keep this repository simple and avoid rewriting code to deal with segment merging or quality selection menus.
## Retrieving Hulu Session Cookie
Hulu requires Recaptcha for authentication so just passing account credentials is not possible without captcha solving services. To work around this, this tool simply takes a Hulu session cookie.
> Note: Ensure you are signed in before following these steps.
### Chrome
Visit [https://hulu.com](https://hulu.com). Click the lock icon in the URL bar. Then select the item labelled Cookies. Then find hulu.com in the list, select it, and expand the "Cookies" list with an icon that looks like a folder. Then select the cookie titled `_hulu_session`. Chrome will then show various attributes of this cookie. Right click the area labelled "Content", press select all and then right click again and press copy. The value of the Hulu session cookie is now on your clipboard. A demonstration can be found [here](https://www.cookieyes.com/wp-content/uploads/2021/10/chrome2.mp4).
### Firefox
Visit [https://hulu.com](https://hulu.com). Right click and then click Inspect. Then visit the Storage tab. Now, under the cookies pane on the left, select hulu.com. Then retrieve the value of `_hulu_session` from the list of cookies. A demonstration can be found [here](https://www.cookieyes.com/wp-content/uploads/2021/10/firefox1.mp4).
## Demonstration
Say we want to download an episode of M\*A\*S\*H.
$ go install github.com/chris124567/hulu # The rest of these commands assume $GOPATH/bin is in your PATH. If it is not, just cd to $GOPATH/bin and run "./hulu" instead of "hulu"
$ HULU_SESSION="abc" hulu search -query="m*a*s*h"
Title ID
M*A*S*H ae94231d-0f04-482a-b9ee-9911e339e3ed
MASH (1970) 42f7eefe-2448-4ed5-87cb-6233c89c20f6
American Psycho (2000) 404a410c-ef36-469d-8fcd-1f93ec44a5c0
American Horror Story a67a233c-fcfe-4e8e-b000-052603ddd616
Hitman: Agent 47 (2015) a4d96c8d-ba7d-4d99-b4b3-942ecde47282
Ma (2019) dbb13a18-79d2-4567-8ed4-e2eddbec9492
The Martian (2015) e52328e3-6e2b-4565-91d5-2f7ee7c846ab
HBO Max 1b3523c1-3090-4c27-a1e8-a04d33867c34
...
We want the M\*A\*S\*H TV show, so we choose the ID "ae94231d-0f04-482a-b9ee-9911e339e3ed." We want to look at the first season so we specify that the season number equals 1.
$ HULU_SESSION="abc" hulu season -id="ae94231d-0f04-482a-b9ee-9911e339e3ed" -number=1
Title ID
Pilot 4045ee04-07e8-4c33-94a6-4244b7b67c5f
To Market, to Market 7a43d075-2b47-4c94-8767-8531e20bab81
Requiem for a Lightweight 2ccd2cf5-a013-4501-a689-1ed6b94a9549
Chief Surgeon Who? 112b061b-1c18-4f15-bed8-042d44919735
The Moose 688e10d3-6db4-47ba-a99b-bfd8aacd6c7a
Yankee Doodle Doctor 3ff14d70-e2ac-4bc2-83c6-87b9cf132c13
...
Now to get the episode we want (the pilot), pass the ID of the episode to the `download` subcommand.
> Note: If we wanted to download a movie, instead of getting the episode list (which movies don't have) and selecting the specific episode ID, just pass the original ID from the search results above to `download`.
$ HULU_SESSION="abc" hulu download -id="4045ee04-07e8-4c33-94a6-4244b7b67c5f"
MPD URL: https://manifest-dp.hulustream.com/OMITTED
Decryption command: mp4decrypt input.mp4 output.mp4 --key OMITTED:OMITTED
Now we have the URL and the keys. First, let's see what formats are available:
$ yt-dlp --allow-unplayable-formats -F "https://manifest-dp.hulustream.com/OMITTED"
WARNING: You have asked for unplayable formats to be listed/downloaded. This is a developer option intended for debugging.
If you experience any issues while using this option, DO NOT open a bug report
[generic] xxxxxxxx: Requesting header
WARNING: [generic] Falling back on generic information extractor.
[generic] xxxxxxxx: Downloading webpage
[generic] xxxxxxxx: Extracting information
[info] Available formats for xxxxxxxx:
ID EXT RESOLUTION | TBR PROTO | VCODEC VBR ACODEC ABR ASR MORE INFO
---------------- --- ---------- - ----- ----- - ----------- ----- --------- ---- ------- --------------------------
132545434.add-0 m4a audio only | 68k https | mp4a.40.5 68k 48000Hz [en], DASH audio, m4a_dash
132545434.add-1 m4a audio only | 68k https | mp4a.40.5 68k 48000Hz [en], DASH audio, m4a_dash
...
132545434.add-11 m4a audio only | 68k https | mp4a.40.5 68k 48000Hz [en], DASH audio, m4a_dash
132545134.vdd-0 mp4 512x288 | 460k https | avc1.640015 460k DASH video, mp4_dash
132545134.vdd-1 mp4 512x288 | 460k https | avc1.640015 460k DASH video, mp4_dash
...
132545134.vdd-11 mp4 512x288 | 460k https | avc1.640015 460k DASH video, mp4_dash
Let's get the audio first. We will choose `132545434.add-0`, the lowest quality format, for this example. Download it with:
$ yt-dlp --allow-unplayable-formats -f "132545434.add-0" "https://manifest-dp.hulustream.com/OMITTED" -o audio.mp4
Next we will get the video. We will also just take the lowest quality format (`132545134.vdd-0`) here.
$ yt-dlp --allow-unplayable-formats -f "132545134.vdd-0" "https://manifest-dp.hulustream.com/OMITTED" -o video.mp4
Now we should have two mp4 files, one for the video and one for the audio. We ultimately will merge these, but first we need to decrypt them.
Remember the mp4decrypt command from above? Specifically look at the `--key OMITTED:OMITTED` part. The decryption key is the same for both the video and the audio. So we can run:
$ mp4decrypt audio.mp4 audio_dec.mp4 --key OMITTED:OMITTED
$ mp4decrypt video.mp4 video_dec.mp4 --key OMITTED:OMITTED
Finally, we can merge the two sources (this command does not do any reencoding):
$ ffmpeg -i video_dec.mp4 -i audio_dec.mp4 -acodec copy -vcodec copy merged.mp4
And now merged.mp4 will be a DRM free mp4 file straight from Hulu! It is possible to automate nearly all of these steps by writing a simple script.
## TODO
- Subtitles
- Storing authentication cookie in a text file to avoid having to pass it for every command
## Credits
The bulk of the Widevine related code was ported from `pywidevine` which is a library floating around the Internet of unknown provenance.

11
go.mod Normal file
View File

@ -0,0 +1,11 @@
module github.com/chris124567/hulu
go 1.16
require (
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1
golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
google.golang.org/protobuf v1.27.1
lukechampine.com/flagg v1.1.1
lukechampine.com/frand v1.4.2
)

19
go.sum Normal file
View File

@ -0,0 +1,19 @@
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1 h1:+JkXLHME8vLJafGhOH4aoV2Iu8bR55nU6iKMVfYVLjY=
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1/go.mod h1:nuudZmJhzWtx2212z+pkuy7B6nkBqa+xwNXZHL1j8cg=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
lukechampine.com/flagg v1.1.1 h1:jB5oL4D5zSUrzm5og6dDEi5pnrTF1poKfC7KE1lLsqc=
lukechampine.com/flagg v1.1.1/go.mod h1:a9ZuZu5LSPXELWSJrabRD00ort+lDXSOQu34xWgEoDI=
lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw=
lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s=

232
hulu/client.go Normal file
View File

@ -0,0 +1,232 @@
package hulu
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"lukechampine.com/frand"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
contentTypeJSON = "application/json"
contentTypeForm = "application/x-www-form-urlencoded"
)
type Client struct {
c *http.Client
huluSession string
huluGUID string
}
// Returns a Client object that will use the provided Hulu session cookie to
// interact with the Hulu API.
func NewClient(c *http.Client, huluSession string) Client {
// they look something like 5E95F69687FDD039CD0388A39FC01E5A
huluGUID := func() (s string) {
c := []byte("ABCDEF0123456789")
for i := 0; i < 32; i++ {
s += string(c[frand.Intn(len(c))])
}
return
}()
return Client{c, huluSession, huluGUID}
}
// Returns a Client object using a default HTTP client with a timeout of 10s.
func NewDefaultClient(huluSession string) Client {
return NewClient(&http.Client{
Timeout: 10 * time.Second,
}, huluSession)
}
// Makes an HTTP request to a Hulu API endpoint. The only cookie Hulu validates is
// the session cookie so we just provide it alone.
func (c Client) request(method string, url string, data io.Reader, contentType string) (*http.Response, error) {
request, err := http.NewRequest(method, url, data)
if err != nil {
return nil, err
}
request.Close = true
request.Header = StandardHeaders()
request.Header.Set("Cookie", "_hulu_session="+c.huluSession)
if method == http.MethodPost && len(contentType) > 0 {
request.Header.Set("Content-Type", contentType)
}
return c.c.Do(request)
}
// Queries the Hulu entity search API endpoint for shows and movies. This can
// return content that you do not have the right subscription for (like stuff
// requiring an HBO subscription) so be mindful of that.
func (c Client) Search(query string) (s SearchResults, err error) {
query = url.QueryEscape(query)
response, err := c.request(http.MethodGet, fmt.Sprintf("https://discover.hulu.com/content/v5/search/entity?language=en&device_context_id=2&search_query=%s&limit=64&include_offsite=true&v=26e1061d-68ec-48bf-be5a-b2f704d37256&schema=1&device_info=web:3.29.0&referralHost=production&keywords=%s&type=entity&limit=64", query, query), nil, "")
if err != nil {
return
}
defer response.Body.Close()
err = json.NewDecoder(response.Body).Decode(&s)
return
}
// Returns the season information containing the episode list in a given season
// for a given show.
func (c Client) Season(id string, season int) (s Season, err error) {
response, err := c.request(http.MethodGet, fmt.Sprintf("https://discover.hulu.com/content/v5/hubs/series/%s/season/%d?limit=999&schema=1&offset=0&device_info=web:3.29.0&referralHost=production", id, season), nil, "")
if err != nil {
return
}
defer response.Body.Close()
err = json.NewDecoder(response.Body).Decode(&s)
return
}
// The /config endpoint returns a large hex encoded string. This string then
// has to be decoded using a hardcoded key from Hulu. The decoded data is JSON
// containing a bunch of configuration options for the player. More importantly,
// it contains the KeyID field which is needed to call Playlist.
func (c Client) ServerConfig() (co Config, err error) {
rv := strconv.Itoa(int(frand.Uint64n(1e6)))
base := strings.Join([]string{hex.EncodeToString(deejayKey), strconv.Itoa(deejayDeviceID), strconv.Itoa(deejayKeyVersion), rv}, ",")
nonce := md5.Sum([]byte(base))
values := url.Values{}
values.Add("app_version", strconv.Itoa(deejayKeyVersion))
values.Add("badging", "true")
values.Add("device", strconv.Itoa(deejayDeviceID))
values.Add("device_id", c.huluGUID)
values.Add("encrypted_nonce", hex.EncodeToString(nonce[:]))
values.Add("language", "en")
values.Add("region", "US")
values.Add("rv", rv)
values.Add("version", strconv.Itoa(deejayKeyVersion))
response, err := c.request(http.MethodPost, "https://play.hulu.com/config", strings.NewReader(values.Encode()), contentTypeForm)
if err != nil {
return
}
defer response.Body.Close()
ciphertext, err := io.ReadAll(hex.NewDecoder(response.Body))
if err != nil {
return
}
block, err := aes.NewCipher(deejayKey)
if err != nil {
return
}
dec := cipher.NewCBCDecrypter(block, make([]byte, 16))
unpad := func(b []byte) []byte {
if len(b) == 0 {
return b
}
// pks padding is designed so that the value of all the padding bytes is
// the number of padding bytes repeated so to figure out how many
// padding bytes there are we can just look at the value of the last
// byte
// i.e if there are 6 padding bytes then it will look at like
// <data> 0x6 0x6 0x6 0x6 0x6 0x6
count := int(b[len(b)-1])
return b[0 : len(b)-count]
}
plaintext := make([]byte, len(ciphertext))
dec.CryptBlocks(plaintext, ciphertext)
err = json.Unmarshal(unpad(plaintext), &co)
return
}
// This allows us to get the EAB ID for a given plain ID. The EAB ID is
// necessary to call Playlist.
func (c Client) PlaybackInformation(id string) (p PlaybackInformation, err error) {
response, err := c.request(http.MethodGet, fmt.Sprintf("https://discover.hulu.com/content/v5/deeplink/playback?namespace=entity&id=%s&schema=1&device_info=web:3.29.0&referralHost=production", id), nil, "")
if err != nil {
return
}
defer response.Body.Close()
err = json.NewDecoder(response.Body).Decode(&p)
return
}
// Playlist returns information containing the Widevine license endpoint,
// the MPD file URL, and information relating to subtitles (Hulu calls them
// transcripts).
func (c Client) Playlist(sessionKey int, eabID string) (p Playlist, err error) {
randUUID := func() (s string) {
randChars := func(n int) (s string) {
c := []byte("ABCDEF0123456789")
for i := 0; i < 4; i++ {
s += string(c[frand.Intn(len(c))])
}
return
}
return strings.Join([]string{randChars(8), randChars(4), randChars(4), randChars(4), randChars(12)}, "-")
}
playlistRequest := PlaylistRequest{
DeviceIdentifier: c.huluGUID + ":d40b",
DeejayDeviceID: deejayDeviceID,
Version: deejayKeyVersion,
AllCdn: true,
ContentEabID: eabID,
Region: "US",
XlinkSupport: false,
DeviceAdID: randUUID(),
LimitAdTracking: false,
IgnoreKidsBlock: false,
Language: "en",
GUID: c.huluGUID,
Rv: int(frand.Uint64n(1e7)),
Kv: sessionKey,
Unencrypted: true,
IncludeT2RevenueBeacon: "1",
CpSessionID: randUUID(),
NetworkMode: "wifi",
PlayIntent: "resume",
Playback: PlaylistRequestPlayback{
Version: 2,
Video: PlaylistRequestVideo{Codecs: PlaylistRequestCodecs{Values: []PlaylistRequestValues{{Type: "H264", Profile: "HIGH", Level: "4.1", Framerate: 30}}, SelectionMode: "ONE"}},
Audio: PlaylistRequestAudio{Codecs: PlaylistRequestCodecs{Values: []PlaylistRequestValues{{Type: "AAC"}}, SelectionMode: "ONE"}},
DRM: PlaylistRequestDRM{Values: []PlaylistRequestValues{{Type: "WIDEVINE", Version: "MODULAR", SecurityLevel: "L3"}}, SelectionMode: "ONE"},
Manifest: PlaylistRequestManifest{
Type: "DASH",
HTTPS: true,
MultipleCdns: true,
PatchUpdates: true,
HuluTypes: true,
LiveDai: true,
MultiplePeriods: true,
Xlink: false,
SecondaryAudio: true,
LiveFragmentDelay: 3,
},
Segments: PlaylistRequestSegments{Values: []PlaylistRequestValues{{Type: "FMP4", Encryption: &PlaylistRequestEncryption{Mode: "CENC", Type: "CENC"}, HTTPS: true}}, SelectionMode: "ONE"},
},
}
var buf bytes.Buffer
if err = json.NewEncoder(&buf).Encode(playlistRequest); err != nil {
return
}
response, err := c.request(http.MethodPost, "https://play.hulu.com/v6/playlist", &buf, contentTypeJSON)
if err != nil {
return
}
defer response.Body.Close()
err = json.NewDecoder(response.Body).Decode(&p)
return
}

981
hulu/types.go Normal file
View File

@ -0,0 +1,981 @@
package hulu
import (
"net/http"
"time"
)
var (
deejayKey = []byte{110, 191, 200, 79, 60, 48, 66, 23, 178, 15, 217, 166, 108, 181, 149, 127}
)
const (
deejayDeviceID = 190
deejayKeyVersion = 1
)
func StandardHeaders() http.Header {
return http.Header{
http.CanonicalHeaderKey("sec-ch-ua"): []string{`" Not A;Brand";v="99"}, "Chromium";v="96"}, "Google Chrome";v="96"`},
http.CanonicalHeaderKey("sec-ch-ua-mobile"): []string{"?0"},
http.CanonicalHeaderKey("User-Agent"): []string{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36"},
http.CanonicalHeaderKey("sec-ch-ua-platform"): []string{`"Linux"`},
http.CanonicalHeaderKey("Accept"): []string{"*/*"},
http.CanonicalHeaderKey("Origin"): []string{"https://www.hulu.com"},
http.CanonicalHeaderKey("Sec-Fetch-Site"): []string{"same-site"},
http.CanonicalHeaderKey("Sec-Fetch-Mode"): []string{"cors"},
http.CanonicalHeaderKey("Sec-Fetch-Dest"): []string{"empty"},
http.CanonicalHeaderKey("Referer"): []string{"https://www.hulu.com/"},
http.CanonicalHeaderKey("Accept-Language"): []string{"en-US,en;q=0.9"},
}
}
type SearchResults struct {
Groups []struct {
Category string `json:"category"`
Results []struct {
Type string `json:"_type"`
MetricsInfo struct {
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
TargetName string `json:"target_name"`
SelectionTrackingID string `json:"selection_tracking_id"`
} `json:"metrics_info"`
Personalization struct {
BowieContext string `json:"bowie_context"`
Eab string `json:"eab"`
} `json:"personalization"`
DeviceContextFailure bool `json:"device_context_failure"`
ViewTemplate string `json:"view_template"`
Visuals struct {
Artwork struct {
Type string `json:"_type"`
Horizontal struct {
Type string `json:"_type"`
ArtworkType string `json:"artwork_type"`
Image struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageID string `json:"image_id"`
} `json:"image"`
Text string `json:"text"`
} `json:"horizontal"`
} `json:"artwork"`
Headline struct {
Text string `json:"text"`
Index [][]int `json:"index"`
} `json:"headline"`
Body struct {
Text string `json:"text"`
Index [][]int `json:"index"`
} `json:"body"`
ActionText string `json:"action_text"`
PrimaryBranding struct {
ID string `json:"id"`
Name string `json:"name"`
Artwork struct {
BrandWatermarkBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark.bottom.right"`
BrandLogoBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.logo.bottom.right"`
} `json:"artwork"`
} `json:"primary_branding"`
ShortSubtitle struct {
Text string `json:"text"`
Index []interface{} `json:"index"`
} `json:"short_subtitle"`
} `json:"visuals"`
Actions struct {
Browse struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
TargetTheme string `json:"target_theme"`
Params struct {
} `json:"params"`
Href string `json:"href"`
BrowseTheme string `json:"browse_theme"`
MetricsInfo struct {
ActionType string `json:"action_type"`
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
TargetDisplayName string `json:"target_display_name"`
} `json:"metrics_info"`
Type string `json:"type"`
} `json:"browse"`
ContextMenu struct {
Actions []struct {
ActionType string `json:"action_type"`
EntityName string `json:"entity_name"`
EntityType string `json:"entity_type"`
MetricsInfo struct {
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
TargetDisplayName string `json:"target_display_name"`
Eab string `json:"eab"`
Type string `json:"_type"`
} `json:"metrics_info"`
Eab string `json:"eab"`
} `json:"actions"`
Header struct {
Title string `json:"title"`
Artwork struct {
Type string `json:"_type"`
Horizontal struct {
Type string `json:"_type"`
ArtworkType string `json:"artwork_type"`
Image struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageID string `json:"image_id"`
} `json:"image"`
Text string `json:"text"`
} `json:"horizontal"`
Vertical struct {
Type string `json:"_type"`
ArtworkType string `json:"artwork_type"`
Text string `json:"text"`
} `json:"vertical"`
} `json:"artwork"`
PrimaryBranding struct {
ID string `json:"id"`
Name string `json:"name"`
Artwork struct {
BrandWatermarkBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark.bottom.right"`
BrandLogoBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.logo.bottom.right"`
} `json:"artwork"`
} `json:"primary_branding"`
Action struct {
ActionType string `json:"action_type"`
EntityName string `json:"entity_name"`
EntityType string `json:"entity_type"`
MetricsInfo struct {
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
TargetDisplayName string `json:"target_display_name"`
Eab string `json:"eab"`
Type string `json:"_type"`
} `json:"metrics_info"`
Browse struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetTheme string `json:"target_theme"`
Params struct {
} `json:"params"`
Type string `json:"type"`
} `json:"browse"`
TargetName string `json:"target_name"`
Href string `json:"href"`
} `json:"action"`
} `json:"header"`
} `json:"context_menu"`
} `json:"actions"`
EntityMetadata struct {
GenreNames []string `json:"genre_names"`
PremiereDate time.Time `json:"premiere_date"`
Rating struct {
Code string `json:"code"`
} `json:"rating"`
TargetName string `json:"target_name"`
IsWarm bool `json:"is_warm"`
} `json:"entity_metadata"`
} `json:"results"`
} `json:"groups"`
Metadata struct {
SearchResultType string `json:"search_result_type"`
Explanation string `json:"explanation"`
SelectionTrackingID string `json:"selection_tracking_id"`
} `json:"metadata"`
DeviceContextFailure bool `json:"device_context_failure"`
}
type Season struct {
Type string `json:"_type"`
ID string `json:"id"`
Href string `json:"href"`
P13NHref string `json:"p13n_href"`
Name string `json:"name"`
Theme string `json:"theme"`
Artwork struct {
} `json:"artwork"`
DeviceContextFailure bool `json:"device_context_failure"`
Items []struct {
Type string `json:"_type"`
ID string `json:"id"`
Href string `json:"href"`
Name string `json:"name"`
Description string `json:"description"`
Artwork struct {
VideoHorizontalHero struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"video.horizontal.hero"`
} `json:"artwork"`
MetricsInfo struct {
Type string `json:"_type"`
MetricsAssetName string `json:"metrics_asset_name"`
AiringType string `json:"airing_type"`
ExternalIdentifiers []struct {
Namespace string `json:"namespace"`
ID string `json:"id"`
} `json:"external_identifiers"`
} `json:"metrics_info"`
Personalization struct {
Eab string `json:"eab"`
} `json:"personalization"`
DeviceContextFailure bool `json:"device_context_failure"`
Browse struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetTheme string `json:"target_theme"`
Params struct {
} `json:"params"`
Href string `json:"href"`
BrowseTheme string `json:"browse_theme"`
Type string `json:"type"`
} `json:"browse"`
SeriesID string `json:"series_id"`
SeriesName string `json:"series_name"`
Season string `json:"season"`
SeasonShortDisplayName string `json:"season_short_display_name"`
Bundle struct {
Type string `json:"_type"`
ID int `json:"id"`
EabID string `json:"eab_id"`
NetworkID string `json:"network_id"`
NetworkName string `json:"network_name"`
Duration int `json:"duration"`
Availability struct {
Type string `json:"_type"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
LocationRequirement string `json:"location_requirement"`
IsAvailable bool `json:"is_available"`
} `json:"availability"`
BundleType string `json:"bundle_type"`
Rating string `json:"rating"`
OpenCreditEndPos int `json:"open_credit_end_pos"`
CloseCreditStartPos int `json:"close_credit_start_pos"`
Rights struct {
Startover bool `json:"startover"`
Recordable bool `json:"recordable"`
Offline bool `json:"offline"`
ClientOverride bool `json:"client_override"`
} `json:"rights"`
CpID int `json:"cp_id"`
AllEtag string `json:"all_etag"`
RightsEtag string `json:"rights_etag"`
AiringsEtag string `json:"airings_etag"`
StreamEtag string `json:"stream_etag"`
RightsTTL int `json:"rights_ttl"`
AiringsTTL int `json:"airings_ttl"`
StreamTTL int `json:"stream_ttl"`
PackageID int `json:"package_id"`
AvFeatures []string `json:"av_features"`
} `json:"bundle"`
Number string `json:"number"`
PrimaryBranding struct {
ID string `json:"id"`
Name string `json:"name"`
Artwork struct {
BrandWatermark struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark"`
BrandWatermarkDark struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark.dark"`
BrandWatermarkTopRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark.top.right"`
BrandLogo struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.logo"`
NetworkTile struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"network.tile"`
BrandWatermarkBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.watermark.bottom.right"`
BrandLogoTopRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.logo.top.right"`
BrandLogoBottomRight struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"brand.logo.bottom.right"`
} `json:"artwork"`
} `json:"primary_branding"`
Rating struct {
Code string `json:"code"`
} `json:"rating"`
GenreNames []string `json:"genre_names"`
PremiereDate time.Time `json:"premiere_date"`
Duration int `json:"duration"`
IsFirstRun bool `json:"is_first_run"`
SeriesArtwork struct {
DetailVerticalHero struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"detail.vertical.hero"`
TitleTreatmentHorizontal struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"title.treatment.horizontal"`
ProgramTile struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"program.tile"`
ProgramVerticalTile struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"program.vertical.tile"`
TitleTreatmentStacked struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"title.treatment.stacked"`
DetailHorizontalHero struct {
Path string `json:"path"`
Accent struct {
Hue int `json:"hue"`
Classification string `json:"classification"`
} `json:"accent"`
ImageType string `json:"image_type"`
ImageID string `json:"image_id"`
} `json:"detail.horizontal.hero"`
} `json:"series_artwork"`
RestrictionLevel string `json:"restriction_level"`
Exclusivity string `json:"exclusivity"`
Actions []interface{} `json:"actions"`
} `json:"items"`
Pagination struct {
CurrentOffset int `json:"current_offset"`
TotalCount int `json:"total_count"`
} `json:"pagination"`
SeriesGroupingMetadata struct {
SeriesGroupingType string `json:"series_grouping_type"`
SeasonNumber int `json:"season_number"`
GroupingName string `json:"groupingName"`
Unknown bool `json:"unknown"`
} `json:"series_grouping_metadata"`
}
type PlaybackInformation struct {
Type string `json:"_type"`
Browse struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetTheme string `json:"target_theme"`
Params struct {
} `json:"params"`
Type string `json:"type"`
} `json:"browse"`
EabID string `json:"eab_id"`
Href string `json:"href"`
ID string `json:"id"`
HrefType string `json:"href_type"`
RestrictionLevel string `json:"restriction_level"`
}
type PlaylistRequest struct {
DeviceIdentifier string `json:"device_identifier"`
DeejayDeviceID int `json:"deejay_device_id"`
Version int `json:"version"`
AllCdn bool `json:"all_cdn"`
ContentEabID string `json:"content_eab_id"`
Region string `json:"region"`
XlinkSupport bool `json:"xlink_support"`
DeviceAdID string `json:"device_ad_id"`
LimitAdTracking bool `json:"limit_ad_tracking"`
IgnoreKidsBlock bool `json:"ignore_kids_block"`
Language string `json:"language"`
GUID string `json:"guid"`
Rv int `json:"rv"`
Kv int `json:"kv"`
Unencrypted bool `json:"unencrypted"`
IncludeT2RevenueBeacon string `json:"include_t2_revenue_beacon"`
CpSessionID string `json:"cp_session_id"`
InterfaceVersion string `json:"interface_version"`
NetworkMode string `json:"network_mode"`
PlayIntent string `json:"play_intent"`
Playback PlaylistRequestPlayback `json:"playback"`
}
type PlaylistRequestValues struct {
Type string `json:"type,omitempty"`
Profile string `json:"profile,omitempty"`
Level string `json:"level,omitempty"`
Framerate int `json:"framerate,omitempty"`
Version string `json:"version,omitempty"`
SecurityLevel string `json:"security_level,omitempty"`
Encryption *PlaylistRequestEncryption `json:"encryption,omitempty"`
HTTPS bool `json:"https,omitempty"`
}
type PlaylistRequestCodecs struct {
Values []PlaylistRequestValues `json:"values"`
SelectionMode string `json:"selection_mode"`
}
type PlaylistRequestVideo struct {
Codecs PlaylistRequestCodecs `json:"codecs"`
}
type PlaylistRequestAudio struct {
Codecs PlaylistRequestCodecs `json:"codecs"`
}
type PlaylistRequestDRM struct {
Values []PlaylistRequestValues `json:"values"`
SelectionMode string `json:"selection_mode"`
}
type PlaylistRequestManifest struct {
Type string `json:"type"`
HTTPS bool `json:"https"`
MultipleCdns bool `json:"multiple_cdns"`
PatchUpdates bool `json:"patch_updates"`
HuluTypes bool `json:"hulu_types"`
LiveDai bool `json:"live_dai"`
MultiplePeriods bool `json:"multiple_periods"`
Xlink bool `json:"xlink"`
SecondaryAudio bool `json:"secondary_audio"`
LiveFragmentDelay int `json:"live_fragment_delay"`
}
type PlaylistRequestEncryption struct {
Mode string `json:"mode"`
Type string `json:"type"`
}
type PlaylistRequestSegments struct {
Values []PlaylistRequestValues `json:"values"`
SelectionMode string `json:"selection_mode"`
}
type PlaylistRequestPlayback struct {
Version int `json:"version"`
Video PlaylistRequestVideo `json:"video"`
Audio PlaylistRequestAudio `json:"audio"`
DRM PlaylistRequestDRM `json:"drm"`
Manifest PlaylistRequestManifest `json:"manifest"`
Segments PlaylistRequestSegments `json:"segments"`
}
type Playlist struct {
UseManifestBreaks bool `json:"use_manifest_breaks"`
Adstate string `json:"adstate"`
Breaks []interface{} `json:"breaks"`
ContentEabID string `json:"content_eab_id"`
TranscriptsUrls struct {
Smi struct {
En string `json:"en"`
} `json:"smi"`
Webvtt struct {
En string `json:"en"`
} `json:"webvtt"`
Ttml struct {
En string `json:"en"`
} `json:"ttml"`
} `json:"transcripts_urls"`
TranscriptsEncryptionKey string `json:"transcripts_encryption_key"`
VideoMetadata struct {
AspectRatio string `json:"aspect_ratio"`
EndCreditsTime string `json:"end_credits_time"`
FrameRate int `json:"frame_rate"`
HasBug string `json:"has_bug"`
HasCaptions bool `json:"has_captions"`
HasNetworkPreRoll bool `json:"has_network_pre_roll"`
Interstitials string `json:"interstitials"`
Language string `json:"language"`
Length int `json:"length"`
Segments string `json:"segments"`
ID int `json:"id"`
AssetID int `json:"asset_id"`
Markers interface{} `json:"markers"`
TranscriptsDefaultOn bool `json:"transcripts_default_on"`
RatingBugBig string `json:"rating_bug_big"`
RatingBugSmall string `json:"rating_bug_small"`
} `json:"video_metadata"`
TranscriptsEncryptionIv string `json:"transcripts_encryption_iv"`
Breakhash string `json:"breakhash"`
AdBreakTimes []int `json:"ad_break_times"`
TranscriptsDefaultOn bool `json:"transcripts_default_on"`
ResumePosition int `json:"resume_position"`
RecordingOffset int `json:"recording_offset"`
InitialPosition int `json:"initial_position"`
DashPrServer string `json:"dash_pr_server"`
WvServer string `json:"wv_server"`
AudioTracks []struct {
Language string `json:"language"`
Role string `json:"role"`
CodecsString string `json:"codecs_string"`
Channels int `json:"channels"`
} `json:"audio_tracks"`
MbrManifest string `json:"mbr_manifest"`
StreamURL string `json:"stream_url"`
ThumbnailEndpoint string `json:"thumbnail_endpoint"`
AssetPlaybackType string `json:"asset_playback_type"`
SauronID string `json:"sauron_id"`
ViewTTLMillis int `json:"view_ttl_millis"`
SauronToken string `json:"sauron_token"`
SauronTokenTTL int `json:"sauron_token_ttl"`
SauronTokenTTLMs int `json:"sauron_token_ttl_ms"`
}
type Config struct {
PassThroughQos string `json:"pass_through_qos"`
Kinko string `json:"kinko"`
PackageID int `json:"package_id"`
API string `json:"api"`
QosBeacon string `json:"qos_beacon"`
NielsenAppName string `json:"nielsen_app_name"`
FeedbackCategory int `json:"feedbackCategory"`
PlusPlanID int `json:"plus_plan_id"`
FirehoseEndpoint string `json:"firehose_endpoint"`
PbAutoresumeTimeout int `json:"pb_autoresume_timeout"`
SashProductDescription string `json:"sash_product_description"`
PlaylistEndpoint string `json:"playlist_endpoint"`
NielsenAppID string `json:"nielsen_app_id"`
PackageGroupID int `json:"package_group_id"`
FlexActionEndpoint string `json:"flex_action_endpoint"`
PlaybackRequestTimeout int `json:"playback_request_timeout"`
Asset string `json:"asset"`
NoahSignupExceptionMessage []string `json:"noah_signup_exception_message"`
PbInterval int `json:"pb_interval"`
PackageGroupIDFrontPorch int `json:"package_group_id_front_porch"`
FlagsContext struct {
FlagStateValid bool `json:"flag_state_valid"`
UILink string `json:"ui_link"`
Key string `json:"key"`
SealTokenState string `json:"seal_token_state"`
} `json:"flags_context"`
SashProductTitle string `json:"sash_product_title"`
TrackTiersDeepPlayerState int `json:"track_tiers_deep_player_state"`
UserAccountURL string `json:"userAccountURL"`
Pgid int `json:"pgid"`
GeokResponse string `json:"geok_response"`
UserInfoURL string `json:"user_info_url"`
ChangePlanURL string `json:"changePlanURL"`
Profiles struct {
PromptAfterIdleMs int `json:"prompt_after_idle_ms"`
} `json:"profiles"`
AutoplayIdleTimeout int `json:"autoplay_idle_timeout"`
NielsenSfCode string `json:"nielsen_sf_code"`
PlusLandingURL string `json:"plusLandingURL"`
NielsenAppVersion string `json:"nielsen_app_version"`
PlayerProgressReportInterval int `json:"player_progress_report_interval"`
ProductInstrumentationV2 struct {
MetricsAgent struct {
Endpoint string `json:"endpoint"`
MaxHitRetries int `json:"max_hit_retries"`
SamplingRatios struct {
ServiceCall float64 `json:"service_call"`
} `json:"sampling_ratios"`
MsPerEvent int `json:"ms_per_event"`
Enabled bool `json:"enabled"`
MaxHitQueueMs int `json:"max_hit_queue_ms"`
EventFilterConfig struct {
} `json:"event_filter_config"`
OnlineAssetMaxBeaconQueueMs int `json:"online_asset_max_beacon_queue_ms"`
DownloadedAssetMaxBeaconQueueMs int `json:"downloaded_asset_max_beacon_queue_ms"`
EventWhitelist []string `json:"event_whitelist"`
NonInteractiveEvents []string `json:"non_interactive_events"`
BucketSize int `json:"bucket_size"`
} `json:"metrics_agent"`
ConvivaAgent struct {
FatalErrors []string `json:"fatal_errors"`
Staging bool `json:"staging"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
GatewayURL string `json:"gateway_url"`
} `json:"conviva_agent"`
MetricsTracker struct {
} `json:"metrics_tracker"`
RateLimiting struct {
SegmentDownloadHit int `json:"segment_download_hit"`
} `json:"rate_limiting"`
AdobeAgent struct {
AppMeasurementTrackingServer string `json:"app_measurement_tracking_server"`
VisitorMcid string `json:"visitor_mcid"`
Enabled bool `json:"enabled"`
AppMeasurementRsid string `json:"app_measurement_rsid"`
VisitorTrackingServer string `json:"visitor_tracking_server"`
HeartbeatTrackingServer string `json:"heartbeat_tracking_server"`
} `json:"adobe_agent"`
MoatAgent struct {
Enabled bool `json:"enabled"`
} `json:"moat_agent"`
AdobeAgentV2 struct {
AppMeasurementTrackingServer string `json:"app_measurement_tracking_server"`
VisitorMcid string `json:"visitor_mcid"`
Enabled bool `json:"enabled"`
AppMeasurementRsid string `json:"app_measurement_rsid"`
VisitorTrackingServer string `json:"visitor_tracking_server"`
HeartbeatTrackingServer string `json:"heartbeat_tracking_server"`
} `json:"adobe_agent_v2"`
} `json:"product_instrumentation_v2"`
FeedbackURL string `json:"feedbackURL"`
IsAnonProxy bool `json:"is_anon_proxy"`
NielsenEnabled string `json:"nielsen_enabled"`
EurekaNamespace string `json:"eureka_namespace"`
ReportGeocheckURL string `json:"reportGeocheckURL"`
PbTracker string `json:"pb_tracker"`
MetricsAgent struct {
MaxBatchesBuffered int `json:"max_batches_buffered"`
MaxBatchSize int `json:"max_batch_size"`
Enabled bool `json:"enabled"`
EventFilterConfig struct {
ServiceCall struct {
EventRules []struct {
RuleType string `json:"rule_type"`
RuleFilter struct {
Type string `json:"type,omitempty"`
Dimension string `json:"dimension,omitempty"`
Value string `json:"value,omitempty"`
Filters []struct {
Type string `json:"type,omitempty"`
Dimension string `json:"dimension,omitempty"`
Value string `json:"value,omitempty"`
} `json:"filters,omitempty"`
} `json:"rule_filter,omitempty"`
} `json:"event_rules"`
} `json:"service_call"`
Log struct {
EventRules []struct {
RuleType string `json:"rule_type"`
RuleFilter struct {
Filter struct {
Type string `json:"type"`
Filters []struct {
Type string `json:"type"`
Dimension string `json:"dimension"`
Value string `json:"value"`
} `json:"filters"`
} `json:"filter"`
Type string `json:"type"`
} `json:"rule_filter"`
} `json:"event_rules"`
} `json:"log"`
} `json:"event_filter_config"`
Endpoint string `json:"endpoint"`
FlushInterval int `json:"flush_interval"`
} `json:"metrics_agent"`
Iball string `json:"iball"`
HuluMbr string `json:"hulu_mbr"`
NoahSignupExceptionShows []struct {
ID int `json:"id"`
Title string `json:"title"`
} `json:"noah_signup_exception_shows"`
BeaconConfig string `json:"beacon_config"`
Sapi string `json:"sapi"`
Csel string `json:"csel"`
ProfileBitrates []int `json:"profile_bitrates"`
PlusLearnMoreURL string `json:"plusLearnMoreURL"`
NoahProductDescription string `json:"noah_product_description"`
SauronAccessToken string `json:"sauron_access_token"`
EurekaApplicationID string `json:"eureka_application_id"`
GeokLocation string `json:"geok_location"`
PlusInviteURL string `json:"plusInviteURL"`
HothHost string `json:"hoth_host"`
PlaybackRequestRetries int `json:"playback_request_retries"`
BadgingConfig []struct {
Text string `json:"text"`
State string `json:"state"`
Style string `json:"style"`
} `json:"badging_config"`
AdServer string `json:"ad_server"`
RtBeacon string `json:"rt_beacon"`
EndpointUrls struct {
PlaylistV4 string `json:"playlist_v4"`
PlaylistV5 string `json:"playlist_v5"`
PlaylistV6 string `json:"playlist_v6"`
UserStateV5 string `json:"user_state_v5"`
WatchDownloadV1 string `json:"watch_download_v1"`
DvrRecordingsV1 string `json:"dvr_recordings_v1"`
UserV1 string `json:"user_v1"`
FlexActionV1 string `json:"flex_action_v1"`
BrowseV5 string `json:"browse_v5"`
UserBookmarksV1 string `json:"user_bookmarks_v1"`
GuideV0 string `json:"guide_v0"`
UserTastesV5 string `json:"user_tastes_v5"`
VortexV0 string `json:"vortex_v0"`
ConvivaV0 string `json:"conviva_v0"`
DvrRecordingSettingsV1 string `json:"dvr_recording_settings_v1"`
DvrV1 string `json:"dvr_v1"`
ConfigV0 string `json:"config_v0"`
EmuV0 string `json:"emu_v0"`
SauronV1 string `json:"sauron_v1"`
PlaybackFeaturesV0 string `json:"playback_features_v0"`
OfflinePlaylistV1 string `json:"offline_playlist_v1"`
AuthV1 string `json:"auth_v1"`
AuthV2 string `json:"auth_v2"`
OnboardingV5 string `json:"onboarding_v5"`
AuthAppleAuthnRequestV0 string `json:"auth_apple_authn_request_v0"`
GlobalNavV1 string `json:"global_nav_v1"`
} `json:"endpoint_urls"`
IapGracefulDegradationEnabled bool `json:"iap_graceful_degradation_enabled"`
KeyExpiration int `json:"key_expiration"`
Beacon string `json:"beacon"`
Key string `json:"key"`
EurekaApplicationName string `json:"eureka_application_name"`
DeviceID int `json:"device_id"`
PackageIDFrontPorch int `json:"package_id_front_porch"`
PlaybackFeaturesEndpoint string `json:"playback_features_endpoint"`
NoahSignupExceptionURL string `json:"noah_signup_exception_url"`
ExpirationNoticeHours int `json:"expiration_notice_hours"`
ForgotPasswordURL string `json:"forgotPasswordURL"`
SauronEndpoint string `json:"sauron_endpoint"`
GlobalNavEndpoint string `json:"global_nav_endpoint"`
PassThroughMetric string `json:"pass_through_metric"`
BanyaSec string `json:"banya_sec"`
Nydus string `json:"nydus"`
Flags struct {
HuluClientStandardPromptTheme bool `json:"hulu-client-standard-prompt-theme"`
HuluClientTwoFactorVerify bool `json:"hulu-client-two-factor-verify"`
HuluClientGatewayDeviceRanking bool `json:"hulu-client-gateway-device-ranking"`
HuluWebDemoPlayerVersion struct {
HitchMobilePlaybackProdHuluCom string `json:"hitch-mobile-playback.prod.hulu.com"`
CoviewingProdHuluCom string `json:"coviewing.prod.hulu.com"`
EndcardProdHuluCom string `json:"endcard.prod.hulu.com"`
LocalhostProdHuluCom string `json:"localhost.prod.hulu.com"`
OneplayerProdHuluCom string `json:"oneplayer.prod.hulu.com"`
DevelopProdHuluCom string `json:"develop.prod.hulu.com"`
} `json:"hulu-web-demo-player-version"`
HuluWebChromecastSdkPlayerVersion struct {
Player string `json:"player"`
Options struct {
MultiKey bool `json:"multi-key"`
Hdr bool `json:"hdr"`
Touchstone bool `json:"touchstone"`
} `json:"options"`
Sdk string `json:"sdk"`
} `json:"hulu-web-chromecast-sdk-player-version"`
HuluClientRokuInstantSignupEnabled bool `json:"hulu-client-roku-instant-signup-enabled"`
HuluClientEndCardFg1 bool `json:"hulu-client-end-card-fg1"`
HuluClientPinProtectionEnabled bool `json:"hulu-client-pin-protection-enabled"`
HuluClientPerformanceTracking bool `json:"hulu-client-performance-tracking"`
HuluWebSmokeSitePlayerVersion struct {
Nonsub string `json:"nonsub"`
Sub string `json:"sub"`
} `json:"hulu-web-smoke-site-player-version"`
HuluWebSmokeChromecastSdkPlayerVersion struct {
Player string `json:"player"`
Options struct {
MultipleKey bool `json:"multiple-key"`
MultiKey bool `json:"multi-key"`
Touchstone bool `json:"touchstone"`
} `json:"options"`
Sdk string `json:"sdk"`
} `json:"hulu-web-smoke-chromecast-sdk-player-version"`
HuluClientNeverBlockSvodEnabled bool `json:"hulu-client-never-block-svod-enabled"`
HuluClientUpdatedLocationPrompt bool `json:"hulu-client-updated-location-prompt"`
HuluClientFlexWelcomeEnabled bool `json:"hulu-client-flex-welcome-enabled"`
HuluClientEventPurchaseEnabled bool `json:"hulu-client-event-purchase-enabled"`
HuluWebDevelopProdSitePlayerOptions struct {
CreditEndCardDuration string `json:"credit_end_card_duration"`
SkipButtonDuration string `json:"skip_button_duration"`
EnablePinchZoom bool `json:"enable_pinch_zoom"`
EnabledAdobeAgent bool `json:"enabled_adobe_agent"`
EnabledQueuedSeek bool `json:"enabled_queued_seek"`
} `json:"hulu-web-develop-prod-site-player-options"`
HuluClientExperienceBrandedPageThemeSupport string `json:"hulu-client-experience-branded-page-theme-support"`
HuluClientSignupOnDeviceEnabled bool `json:"hulu-client-signup-on-device-enabled"`
HuluClientNonNumericSeasons bool `json:"hulu-client-non-numeric-seasons"`
HuluClientFlexTimeoutsMs int `json:"hulu-client-flex-timeouts-ms"`
HuluClientDvrRecordingsGroups bool `json:"hulu-client-dvr-recordings-groups"`
HuluClientPlayerBasicsFg1 bool `json:"hulu-client-player-basics-fg-1"`
HuluClientEventPurchasePollingTimeout int `json:"hulu-client-event-purchase-polling-timeout"`
HuluClientNewDvrFeatures bool `json:"hulu-client-new-dvr-features"`
HuluClientFeatureMultikey bool `json:"hulu-client-feature-multikey"`
HuluClientPostPurchaseCollectionID int `json:"hulu-client-post-purchase-collection-id"`
HuluClientPlanSelectExtraCopy struct {
ShowExtraCopy bool `json:"showExtraCopy"`
} `json:"hulu-client-plan-select-extra-copy"`
HuluWebSmokeChromecastPlayerOptions struct {
Touchstone bool `json:"touchstone"`
} `json:"hulu-web-smoke-chromecast-player-options"`
HuluClientCompassViewAllEnabled bool `json:"hulu-client-compass-view-all-enabled"`
HuluClientForcedDcsCapabilities []string `json:"hulu-client-forced-dcs-capabilities"`
HuluWebSitePlayerOptions struct {
CreditEndCardDuration string `json:"credit_end_card_duration"`
EnabledBrightline bool `json:"enabled_brightline"`
EnabledAdobeAgent bool `json:"enabled_adobe_agent"`
EnabledQueuedSeek bool `json:"enabled_queued_seek"`
} `json:"hulu-web-site-player-options"`
HuluClientInAppAccountManagementAddOnsEnabled bool `json:"hulu-client-in-app-account-management-add-ons-enabled"`
HuluClientCompassEnabled bool `json:"hulu-client-compass-enabled"`
HuluClientFeaturePxsSurveyEnabled bool `json:"hulu-client-feature-pxs-survey-enabled"`
HuluClientTrailheadBannerTheme bool `json:"hulu-client-trailhead-banner-theme"`
HuluClientAvMetadataBadgingEnabled bool `json:"hulu-client-av-metadata-badging-enabled"`
HuluClientIdleTimeMs int `json:"hulu-client-idle-time-ms"`
HuluWebDevelopProdSitePlayerVersion struct {
Nonsub string `json:"nonsub"`
Sub string `json:"sub"`
} `json:"hulu-web-develop-prod-site-player-version"`
HuluClientPlayerProgressReportInterval int `json:"hulu-client-player-progress-report-interval"`
HuluClientDeviceTokenLoggingEnabled bool `json:"hulu-client-device-token-logging-enabled"`
HuluClientInAppAccountManagementEnabled bool `json:"hulu-client-in-app-account-management-enabled"`
HuluClientEventPurchaseIdentityVerificationPollingInterval int `json:"hulu-client-event-purchase-identity-verification-polling-interval"`
HuluClientCheckProgramRecordability bool `json:"hulu-client-check-program-recordability"`
HuluClientAutoAccountLinkEnabled bool `json:"hulu-client-auto-account-link-enabled"`
HuluWebSmokeSitePlayerOptions struct {
CreditEndCardDuration string `json:"credit_end_card_duration"`
EnabledAdobeAgent bool `json:"enabled_adobe_agent"`
EnabledQueuedSeek bool `json:"enabled_queued_seek"`
} `json:"hulu-web-smoke-site-player-options"`
HuluClientFliptray2 bool `json:"hulu-client-fliptray-2"`
HuluClientOneplayer bool `json:"hulu-client-oneplayer"`
HuluClientFeaturePxsSurveyConfig struct {
PxsShowPercentage float64 `json:"pxs_show_percentage"`
PxsAutoDismissSeconds int `json:"pxs_auto_dismiss_seconds"`
PxsShowFrequencyDays int `json:"pxs_show_frequency_days"`
} `json:"hulu-client-feature-pxs-survey-config"`
HuluWebBrowseFlags struct {
EditorialActionsEnabled bool `json:"editorialActionsEnabled"`
ContextMenuActionV2Enabled bool `json:"contextMenuActionV2Enabled"`
VideoTileEnabled bool `json:"videoTileEnabled"`
EnableWebp bool `json:"enableWebp"`
VariationName string `json:"variationName"`
} `json:"hulu-web-browse-flags"`
HuluClientDetailsCastAndCrew bool `json:"hulu-client-details-cast-and-crew"`
HuluClientPlanSelectChartEnabled bool `json:"hulu-client-plan-select-chart-enabled"`
HuluClientEndpointURLConfiguration bool `json:"hulu-client-endpoint-url-configuration"`
HuluClientDvrMsbd bool `json:"hulu-client-dvr-msbd"`
HuluClientMyStuffDecoupled bool `json:"hulu-client-my-stuff-decoupled"`
HuluWebSitePlayerVersion struct {
Nonsub string `json:"nonsub"`
Sub string `json:"sub"`
} `json:"hulu-web-site-player-version"`
HuluClientTealiumEventsEnabled bool `json:"hulu-client-tealium-events-enabled"`
HuluWebChromecastPlayerOptions struct {
OverrideAdUnits string `json:"overrideAdUnits"`
Touchstone bool `json:"touchstone"`
} `json:"hulu-web-chromecast-player-options"`
HuluClientEventPurchasePollingBaseInterval int `json:"hulu-client-event-purchase-polling-base-interval"`
HuluClientBrandedCollections bool `json:"hulu-client-branded-collections"`
HuluClientSignupOnWebEnabled bool `json:"hulu-client-signup-on-web-enabled"`
HuluClientCompassSitemapEnabled bool `json:"hulu-client-compass-sitemap-enabled"`
HuluClientAdobeMetrics bool `json:"hulu-client-adobe-metrics"`
HuluClientGatewayAdLegalDisclaimer string `json:"hulu-client-gateway-ad-legal-disclaimer"`
HuluClientFeatureChannelFlipping bool `json:"hulu-client-feature-channel-flipping"`
HuluClientFeaturePlaybackCdnSorting bool `json:"hulu-client-feature-playback-cdn-sorting"`
HuluClientLoginMfa bool `json:"hulu-client-login-mfa"`
HuluClientLinksharingAppsflyer bool `json:"hulu-client-linksharing-appsflyer"`
HuluClientFeatureHdr bool `json:"hulu-client-feature-hdr"`
} `json:"flags"`
NoahProductTitle string `json:"noah_product_title"`
CriterionCollection int `json:"criterion_collection"`
KeyID int `json:"key_id"`
}

170
main.go Normal file
View File

@ -0,0 +1,170 @@
package main
import (
"bytes"
"encoding/hex"
"fmt"
"github.com/chris124567/hulu/hulu"
"github.com/chris124567/hulu/widevine"
"io"
"lukechampine.com/flagg"
"net/http"
"os"
"text/tabwriter"
"time"
)
func main() {
rootCmd := flagg.Root
rootCmd.Usage = flagg.SimpleUsage(rootCmd, `Hulu Downloader
It is necessary to specify the HULU_SESSION environment variable because the Hulu API requires this for all requests.
Subcommands:
search [query] - searches Hulu with the provided query and returns titles and their Hulu IDs
season [id] [season number] - lists episode title and IDs of a given show and season
download [id] - prints the MPD url the video is available at and returns the mp4decrypt command necessary to decrypt it
`)
searchCmd := flagg.New("search", "Search Hulu for a movie or series.")
searchQuery := searchCmd.String("query", "", "Search query.")
seasonCmd := flagg.New("season", "Get information about season in a show by its show ID and season number.")
seasonID := seasonCmd.String("id", "", "ID of series.")
seasonNumber := seasonCmd.Int("number", 1, "Season number.")
downloadCmd := flagg.New("download", "Download a show episode or movie by its ID.")
downloadID := downloadCmd.String("id", "", "ID of movie or episode.")
tree := flagg.Tree{
Cmd: rootCmd,
Sub: []flagg.Tree{
{Cmd: searchCmd},
{Cmd: seasonCmd},
{Cmd: downloadCmd},
},
}
cmd := flagg.Parse(tree)
huluSession := os.Getenv("HULU_SESSION")
if huluSession == "" {
rootCmd.Usage()
return
}
// panic(huluSession)
client := hulu.NewDefaultClient(huluSession)
w := tabwriter.NewWriter(os.Stdout, 8, 8, 0, '\t', 0)
defer w.Flush()
switch cmd {
case searchCmd:
if !flagg.IsDefined(cmd, "query") {
cmd.Usage()
return
}
results, err := client.Search(*searchQuery)
if err != nil {
panic(err)
}
fmt.Fprintf(w, "%s\t%s\t\n", "Title", "ID")
for _, group := range results.Groups {
for _, result := range group.Results {
fmt.Fprintf(w, "%s\t%s\n", result.Visuals.Headline.Text, result.MetricsInfo.TargetID)
}
}
case seasonCmd:
if !flagg.IsDefined(cmd, "id") || !flagg.IsDefined(cmd, "number") {
cmd.Usage()
return
}
results, err := client.Season(*seasonID, *seasonNumber)
if err != nil {
panic(err)
}
fmt.Fprintf(w, "%s\t%s\t\n", "Title", "ID")
for _, item := range results.Items {
fmt.Fprintf(w, "%s\t%s\t\n", item.Name, item.ID)
}
case downloadCmd:
if !flagg.IsDefined(cmd, "id") {
cmd.Usage()
return
}
playbackInformation, err := client.PlaybackInformation(*downloadID)
if err != nil {
panic(err)
}
serverConfig, err := client.ServerConfig()
if err != nil {
panic(err)
}
playlist, err := client.Playlist(serverConfig.KeyID, playbackInformation.EabID)
if err != nil {
panic(err)
}
client := &http.Client{
Timeout: 10 * time.Second,
}
// request MPD file
response, err := client.Get(playlist.StreamURL)
if err != nil {
panic(err)
}
defer response.Body.Close()
// parse init data/PSSH from XML
initData, err := widevine.InitDataFromMPD(response.Body)
if err != nil {
panic(err)
}
cdm, err := widevine.NewDefaultCDM(initData)
if err != nil {
panic(err)
}
licenseRequest, err := cdm.GetLicenseRequest()
if err != nil {
panic(err)
}
request, err := http.NewRequest(http.MethodPost, playlist.WvServer, bytes.NewReader(licenseRequest))
if err != nil {
panic(err)
}
// hulu actually checks for headers here so this is necessary
request.Header = hulu.StandardHeaders()
request.Close = true
// send license request to license server
response, err = client.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
licenseResponse, err := io.ReadAll(response.Body)
if err != nil {
panic(err)
}
// parse keys from response
keys, err := cdm.GetLicenseKeys(licenseRequest, licenseResponse)
if err != nil {
panic(err)
}
command := "mp4decrypt input.mp4 output.mp4"
for _, key := range keys {
if key.Type == widevine.License_KeyContainer_CONTENT {
command += " --key " + hex.EncodeToString(key.ID) + ":" + hex.EncodeToString(key.Value)
}
}
fmt.Println("MPD URL: ", playlist.StreamURL)
fmt.Println("Decryption command: ", command)
return
}
}

468
proto/wv_proto2.proto Normal file
View File

@ -0,0 +1,468 @@
syntax = "proto2";
option go_package = ".;widevine";
// from x86 (partial), most of it from the ARM version:
message ClientIdentification {
enum TokenType {
KEYBOX = 0;
DEVICE_CERTIFICATE = 1;
REMOTE_ATTESTATION_CERTIFICATE = 2;
}
message NameValue {
required string Name = 1;
required string Value = 2;
}
message ClientCapabilities {
enum HdcpVersion {
HDCP_NONE = 0;
HDCP_V1 = 1;
HDCP_V2 = 2;
HDCP_V2_1 = 3;
HDCP_V2_2 = 4;
}
optional uint32 ClientToken = 1;
optional uint32 SessionToken = 2;
optional uint32 VideoResolutionConstraints = 3;
optional HdcpVersion MaxHdcpVersion = 4;
optional uint32 OemCryptoApiVersion = 5;
}
required TokenType Type = 1;
//optional bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
optional SignedDeviceCertificate Token = 2; // use this when parsing, "bytes" when building a client id blob
repeated NameValue ClientInfo = 3;
optional bytes ProviderClientToken = 4;
optional uint32 LicenseCounter = 5;
optional ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
optional FileHashes _FileHashes = 7; // vmp blob goes here
}
message DeviceCertificate {
enum CertificateType {
ROOT = 0;
INTERMEDIATE = 1;
USER_DEVICE = 2;
SERVICE = 3;
}
required CertificateType Type = 1; // the compiled code reused this as ProvisionedDeviceInfo.WvSecurityLevel, however that is incorrect (compiler aliased it as they're both identical as a structure)
optional bytes SerialNumber = 2;
optional uint32 CreationTimeSeconds = 3;
optional bytes PublicKey = 4;
optional uint32 SystemId = 5;
optional uint32 TestDeviceDeprecated = 6; // is it bool or int?
optional bytes ServiceId = 7; // service URL for service certificates
}
// missing some references,
message DeviceCertificateStatus {
enum CertificateStatus {
VALID = 0;
REVOKED = 1;
}
optional bytes SerialNumber = 1;
optional CertificateStatus Status = 2;
optional ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
}
message DeviceCertificateStatusList {
optional uint32 CreationTimeSeconds = 1;
repeated DeviceCertificateStatus CertificateStatus = 2;
}
message EncryptedClientIdentification {
required string ServiceId = 1;
optional bytes ServiceCertificateSerialNumber = 2;
required bytes EncryptedClientId = 3;
required bytes EncryptedClientIdIv = 4;
required bytes EncryptedPrivacyKey = 5;
}
// todo: fill (for this top-level type, it might be impossible/difficult)
enum LicenseType {
ZERO = 0;
DEFAULT = 1; // 1 is STREAMING/temporary license; on recent versions may go up to 3 (latest x86); it might be persist/don't persist type, unconfirmed
OFFLINE = 2;
}
// todo: fill (for this top-level type, it might be impossible/difficult)
// this is just a guess because these globals got lost, but really, do we need more?
enum ProtocolVersion {
CURRENT = 21; // don't have symbols for this
}
message LicenseIdentification {
optional bytes RequestId = 1;
optional bytes SessionId = 2;
optional bytes PurchaseId = 3;
optional LicenseType Type = 4;
optional uint32 Version = 5;
optional bytes ProviderSessionToken = 6;
}
message License {
message Policy {
optional bool CanPlay = 1; // changed from uint32 to bool
optional bool CanPersist = 2;
optional bool CanRenew = 3;
optional uint32 RentalDurationSeconds = 4;
optional uint32 PlaybackDurationSeconds = 5;
optional uint32 LicenseDurationSeconds = 6;
optional uint32 RenewalRecoveryDurationSeconds = 7;
optional string RenewalServerUrl = 8;
optional uint32 RenewalDelaySeconds = 9;
optional uint32 RenewalRetryIntervalSeconds = 10;
optional bool RenewWithUsage = 11; // was uint32
}
message KeyContainer {
enum KeyType {
SIGNING = 1;
CONTENT = 2;
KEY_CONTROL = 3;
OPERATOR_SESSION = 4;
}
enum SecurityLevel {
SW_SECURE_CRYPTO = 1;
SW_SECURE_DECODE = 2;
HW_SECURE_CRYPTO = 3;
HW_SECURE_DECODE = 4;
HW_SECURE_ALL = 5;
}
message OutputProtection {
enum CGMS {
COPY_FREE = 0;
COPY_ONCE = 2;
COPY_NEVER = 3;
CGMS_NONE = 0x2A; // PC default!
}
optional ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
optional CGMS CgmsFlags = 2;
}
message KeyControl {
required bytes KeyControlBlock = 1; // what is this?
required bytes Iv = 2;
}
message OperatorSessionKeyPermissions {
optional uint32 AllowEncrypt = 1;
optional uint32 AllowDecrypt = 2;
optional uint32 AllowSign = 3;
optional uint32 AllowSignatureVerify = 4;
}
message VideoResolutionConstraint {
optional uint32 MinResolutionPixels = 1;
optional uint32 MaxResolutionPixels = 2;
optional OutputProtection RequiredProtection = 3;
}
optional bytes Id = 1;
optional bytes Iv = 2;
optional bytes Key = 3;
optional KeyType Type = 4;
optional SecurityLevel Level = 5;
optional OutputProtection RequiredProtection = 6;
optional OutputProtection RequestedProtection = 7;
optional KeyControl _KeyControl = 8; // duped names, etc
optional OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
}
optional LicenseIdentification Id = 1;
optional Policy _Policy = 2; // duped names, etc
repeated KeyContainer Key = 3;
optional uint32 LicenseStartTime = 4;
optional uint32 RemoteAttestationVerified = 5; // bool?
optional bytes ProviderClientToken = 6;
// there might be more, check with newer versions (I see field 7-8 in a lic)
// this appeared in latest x86:
optional uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
}
message LicenseError {
enum Error {
INVALID_DEVICE_CERTIFICATE = 1;
REVOKED_DEVICE_CERTIFICATE = 2;
SERVICE_UNAVAILABLE = 3;
}
//LicenseRequest.RequestType ErrorCode; // clang mismatch
optional Error ErrorCode = 1;
}
message LicenseRequest {
message ContentIdentification {
message CENC {
//optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
optional WidevineCencHeader Pssh = 1;
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
optional bytes RequestId = 3;
}
message WebM {
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
optional LicenseType LicenseType = 2;
optional bytes RequestId = 3;
}
message ExistingLicense {
optional LicenseIdentification LicenseId = 1;
optional uint32 SecondsSinceStarted = 2;
optional uint32 SecondsSinceLastPlayed = 3;
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
}
optional CENC CencId = 1;
optional WebM WebmId = 2;
optional ExistingLicense License = 3;
}
enum RequestType {
NEW = 1;
RENEWAL = 2;
RELEASE = 3;
}
optional ClientIdentification ClientId = 1;
optional ContentIdentification ContentId = 2;
optional RequestType Type = 3;
optional uint32 RequestTime = 4;
optional bytes KeyControlNonceDeprecated = 5;
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
optional uint32 KeyControlNonce = 7;
optional EncryptedClientIdentification EncryptedClientId = 8;
}
// raw pssh hack
message LicenseRequestRaw {
message ContentIdentification {
message CENC {
optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
//optional WidevineCencHeader Pssh = 1;
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
optional bytes RequestId = 3;
}
message WebM {
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
optional LicenseType LicenseType = 2;
optional bytes RequestId = 3;
}
message ExistingLicense {
optional LicenseIdentification LicenseId = 1;
optional uint32 SecondsSinceStarted = 2;
optional uint32 SecondsSinceLastPlayed = 3;
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
}
optional CENC CencId = 1;
optional WebM WebmId = 2;
optional ExistingLicense License = 3;
}
enum RequestType {
NEW = 1;
RENEWAL = 2;
RELEASE = 3;
}
optional ClientIdentification ClientId = 1;
optional ContentIdentification ContentId = 2;
optional RequestType Type = 3;
optional uint32 RequestTime = 4;
optional bytes KeyControlNonceDeprecated = 5;
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
optional uint32 KeyControlNonce = 7;
optional EncryptedClientIdentification EncryptedClientId = 8;
}
message ProvisionedDeviceInfo {
enum WvSecurityLevel {
LEVEL_UNSPECIFIED = 0;
LEVEL_1 = 1;
LEVEL_2 = 2;
LEVEL_3 = 3;
}
optional uint32 SystemId = 1;
optional string Soc = 2;
optional string Manufacturer = 3;
optional string Model = 4;
optional string DeviceType = 5;
optional uint32 ModelYear = 6;
optional WvSecurityLevel SecurityLevel = 7;
optional uint32 TestDevice = 8; // bool?
}
// todo: fill
message ProvisioningOptions {
}
// todo: fill
message ProvisioningRequest {
}
// todo: fill
message ProvisioningResponse {
}
message RemoteAttestation {
optional EncryptedClientIdentification Certificate = 1;
optional string Salt = 2;
optional string Signature = 3;
}
// todo: fill
message SessionInit {
}
// todo: fill
message SessionState {
}
// todo: fill
message SignedCertificateStatusList {
}
message SignedDeviceCertificate {
//optional bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
optional DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
optional bytes Signature = 2;
optional SignedDeviceCertificate Signer = 3;
}
// todo: fill
message SignedProvisioningMessage {
}
// the root of all messages, from either server or client
message SignedMessage {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
}
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
optional bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
optional bytes SessionKey = 4; // often RSA wrapped for licenses
optional RemoteAttestation RemoteAttestation = 5;
}
// This message is copied from google's docs, not reversed:
message WidevineCencHeader {
enum Algorithm {
UNENCRYPTED = 0;
AESCTR = 1;
};
optional Algorithm algorithm = 1;
repeated bytes key_id = 2;
// Content provider name.
optional string provider = 3;
// A content identifier, specified by content provider.
optional bytes content_id = 4;
// Track type. Acceptable values are SD, HD and AUDIO. Used to
// differentiate content keys used by an asset.
optional string track_type_deprecated = 5;
// The name of a registered policy to be used for this asset.
optional string policy = 6;
// Crypto period index, for media using key rotation.
optional uint32 crypto_period_index = 7;
// Optional protected context for group content. The grouped_license is a
// serialized SignedMessage.
optional bytes grouped_license = 8;
// Protection scheme identifying the encryption algorithm.
// Represented as one of the following 4CC values:
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
optional uint32 protection_scheme = 9;
// Optional. For media using key rotation, this represents the duration
// of each crypto period in seconds.
optional uint32 crypto_period_seconds = 10;
}
// remove these when using it outside of protoc:
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
message SignedLicenseRequest {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
}
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
optional LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
optional bytes SessionKey = 4; // often RSA wrapped for licenses
optional RemoteAttestation RemoteAttestation = 5;
}
// hack
message SignedLicenseRequestRaw {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
}
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
optional LicenseRequestRaw Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
optional bytes SessionKey = 4; // often RSA wrapped for licenses
optional RemoteAttestation RemoteAttestation = 5;
}
message SignedLicense {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
}
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
optional License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
optional bytes SessionKey = 4; // often RSA wrapped for licenses
optional RemoteAttestation RemoteAttestation = 5;
}
message SignedServiceCertificate {
enum MessageType {
LICENSE_REQUEST = 1;
LICENSE = 2;
ERROR_RESPONSE = 3;
SERVICE_CERTIFICATE_REQUEST = 4;
SERVICE_CERTIFICATE = 5;
}
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
optional SignedDeviceCertificate Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
optional bytes SessionKey = 4; // often RSA wrapped for licenses
optional RemoteAttestation RemoteAttestation = 5;
}
//vmp support
message FileHashes {
message Signature {
optional string filename = 1;
optional bool test_signing = 2; //0 - release, 1 - testing
optional bytes SHA512Hash = 3;
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
optional bytes signature = 5;
}
optional bytes signer = 1;
repeated Signature signatures = 2;
}

269
widevine/cdm.go Normal file
View File

@ -0,0 +1,269 @@
package widevine
import (
"bytes"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/pem"
"errors"
"github.com/aead/cmac"
"google.golang.org/protobuf/proto"
"lukechampine.com/frand"
"math"
"time"
)
type CDM struct {
privateKey *rsa.PrivateKey
clientID []byte
sessionID [32]byte
widevineCencHeader WidevineCencHeader
signedDeviceCertificate SignedDeviceCertificate
privacyMode bool
}
type Key struct {
ID []byte
Type License_KeyContainer_KeyType
Value []byte
}
// Creates a new CDM object with the specified device information.
func NewCDM(privateKey string, clientID []byte, initData []byte) (CDM, error) {
block, _ := pem.Decode([]byte(privateKey))
if block == nil || block.Type != "RSA PRIVATE KEY" {
return CDM{}, errors.New("failed to decode device private key")
}
keyParsed, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return CDM{}, err
}
var widevineCencHeader WidevineCencHeader
if len(initData) < 32 {
return CDM{}, errors.New("initData not long enough")
}
if err := proto.Unmarshal(initData[32:], &widevineCencHeader); err != nil {
return CDM{}, err
}
sessionID := func() (s [32]byte) {
c := []byte("ABCDEF0123456789")
for i := 0; i < 16; i++ {
s[i] = c[frand.Intn(len(c))]
}
s[16] = '0'
s[17] = '1'
for i := 18; i < 32; i++ {
s[i] = '0'
}
return s
}()
return CDM{
privateKey: keyParsed,
clientID: clientID,
widevineCencHeader: widevineCencHeader,
sessionID: sessionID,
}, nil
}
// Creates a new CDM object using the default device configuration.
func NewDefaultCDM(initData []byte) (CDM, error) {
return NewCDM(DefaultPrivateKey, DefaultClientID, initData)
}
// Sets a device certificate. This is makes generating the license request
// more complicated but is supported. This is usually not necessary for most
// Widevine applications.
func (c *CDM) SetServiceCertificate(certData []byte) error {
var message SignedMessage
if err := proto.Unmarshal(certData, &message); err != nil {
return err
}
if err := proto.Unmarshal(message.Msg, &c.signedDeviceCertificate); err != nil {
return err
}
c.privacyMode = true
return nil
}
// Generates the license request data. This is sent to the license server via
// HTTP POST and the server in turn returns the license response.
func (c *CDM) GetLicenseRequest() ([]byte, error) {
var licenseRequest SignedLicenseRequest
licenseRequest.Msg = new(LicenseRequest)
licenseRequest.Msg.ContentId = new(LicenseRequest_ContentIdentification)
licenseRequest.Msg.ContentId.CencId = new(LicenseRequest_ContentIdentification_CENC)
// this is probably really bad for the GC but protobuf uses pointers for optional
// fields so it is necessary and this is not a long running program
{
v := SignedLicenseRequest_LICENSE_REQUEST
licenseRequest.Type = &v
}
licenseRequest.Msg.ContentId.CencId.Pssh = &c.widevineCencHeader
{
v := LicenseType_DEFAULT
licenseRequest.Msg.ContentId.CencId.LicenseType = &v
}
licenseRequest.Msg.ContentId.CencId.RequestId = c.sessionID[:]
{
v := LicenseRequest_NEW
licenseRequest.Msg.Type = &v
}
{
v := uint32(time.Now().Unix())
licenseRequest.Msg.RequestTime = &v
}
{
v := ProtocolVersion_CURRENT
licenseRequest.Msg.ProtocolVersion = &v
}
{
v := uint32(frand.Uint64n(math.MaxUint32))
licenseRequest.Msg.KeyControlNonce = &v
}
if c.privacyMode {
pad := func(data []byte, blockSize int) []byte {
padlen := blockSize - (len(data) % blockSize)
if padlen == 0 {
padlen = blockSize
}
return append(data, bytes.Repeat([]byte{byte(padlen)}, padlen)...)
}
const blockSize = 16
var cidKey, cidIV [blockSize]byte
frand.Read(cidKey[:])
frand.Read(cidIV[:])
block, err := aes.NewCipher(cidKey[:])
if err != nil {
return nil, err
}
paddedClientID := pad(c.clientID, blockSize)
encryptedClientID := make([]byte, len(paddedClientID))
cipher.NewCBCEncrypter(block, cidIV[:]).CryptBlocks(encryptedClientID, paddedClientID)
servicePublicKey, err := x509.ParsePKCS1PublicKey(c.signedDeviceCertificate.XDeviceCertificate.PublicKey)
if err != nil {
return nil, err
}
encryptedCIDKey, err := rsa.EncryptOAEP(sha1.New(), frand.Reader, servicePublicKey, cidKey[:], nil)
if err != nil {
return nil, err
}
licenseRequest.Msg.EncryptedClientId = new(EncryptedClientIdentification)
{
v := string(c.signedDeviceCertificate.XDeviceCertificate.ServiceId)
licenseRequest.Msg.EncryptedClientId.ServiceId = &v
}
licenseRequest.Msg.EncryptedClientId.ServiceCertificateSerialNumber = c.signedDeviceCertificate.XDeviceCertificate.SerialNumber
licenseRequest.Msg.EncryptedClientId.EncryptedClientId = encryptedClientID
licenseRequest.Msg.EncryptedClientId.EncryptedClientIdIv = cidIV[:]
licenseRequest.Msg.EncryptedClientId.EncryptedPrivacyKey = encryptedCIDKey
} else {
licenseRequest.Msg.ClientId = new(ClientIdentification)
if err := proto.Unmarshal(c.clientID, licenseRequest.Msg.ClientId); err != nil {
return nil, err
}
}
{
data, err := proto.Marshal(licenseRequest.Msg)
if err != nil {
return nil, err
}
hash := sha1.Sum(data)
if licenseRequest.Signature, err = rsa.SignPSS(frand.Reader, c.privateKey, crypto.SHA1, hash[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}); err != nil {
return nil, err
}
}
return proto.Marshal(&licenseRequest)
}
// Retrieves the keys from the license response data. These keys can be
// used to decrypt the DASH-MP4.
func (c *CDM) GetLicenseKeys(licenseRequest []byte, licenseResponse []byte) (keys []Key, err error) {
var license SignedLicense
if err = proto.Unmarshal(licenseResponse, &license); err != nil {
return
}
var licenseRequestParsed SignedLicenseRequest
if err = proto.Unmarshal(licenseRequest, &licenseRequestParsed); err != nil {
return
}
licenseRequestMsg, err := proto.Marshal(licenseRequestParsed.Msg)
if err != nil {
return
}
sessionKey, err := rsa.DecryptOAEP(sha1.New(), frand.Reader, c.privateKey, license.SessionKey, nil)
if err != nil {
return
}
sessionKeyBlock, err := aes.NewCipher(sessionKey)
if err != nil {
return
}
encryptionKey := []byte{1, 'E', 'N', 'C', 'R', 'Y', 'P', 'T', 'I', 'O', 'N', 0}
encryptionKey = append(encryptionKey, licenseRequestMsg...)
encryptionKey = append(encryptionKey, []byte{0, 0, 0, 0x80}...)
encryptionKeyCmac, err := cmac.Sum(encryptionKey, sessionKeyBlock, sessionKeyBlock.BlockSize())
if err != nil {
return
}
encryptionKeyCipher, err := aes.NewCipher(encryptionKeyCmac)
if err != nil {
return
}
unpad := func(b []byte) []byte {
if len(b) == 0 {
return b
}
// pks padding is designed so that the value of all the padding bytes is
// the number of padding bytes repeated so to figure out how many
// padding bytes there are we can just look at the value of the last
// byte
// i.e if there are 6 padding bytes then it will look at like
// <data> 0x6 0x6 0x6 0x6 0x6 0x6
count := int(b[len(b)-1])
return b[0 : len(b)-count]
}
for _, key := range license.Msg.Key {
decrypter := cipher.NewCBCDecrypter(encryptionKeyCipher, key.Iv)
decryptedKey := make([]byte, len(key.Key))
decrypter.CryptBlocks(decryptedKey, key.Key)
keys = append(keys, Key{
ID: key.Id,
Type: *key.Type,
Value: unpad(decryptedKey),
})
}
return
}

38
widevine/consts.go Normal file

File diff suppressed because one or more lines are too long

115
widevine/pssh.go Normal file
View File

@ -0,0 +1,115 @@
package widevine
import (
"bytes"
"encoding/base64"
"encoding/xml"
"errors"
"io"
"net/http"
)
// This function retrieves the PSSH/Init Data from a given MPD file reader.
// Example file: https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/mpds/11331.mpd
func InitDataFromMPD(r io.Reader) ([]byte, error) {
type mpd struct {
XMLName xml.Name `xml:"MPD"`
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
Profiles string `xml:"profiles,attr"`
Type string `xml:"type,attr"`
AvailabilityStartTime string `xml:"availabilityStartTime,attr"`
PublishTime string `xml:"publishTime,attr"`
MediaPresentationDuration string `xml:"mediaPresentationDuration,attr"`
MinBufferTime string `xml:"minBufferTime,attr"`
Version string `xml:"version,attr"`
Ns2 string `xml:"ns2,attr"`
Xmlns string `xml:"xmlns,attr"`
Bitmovin string `xml:"bitmovin,attr"`
Period struct {
Text string `xml:",chardata"`
AdaptationSet []struct {
Text string `xml:",chardata"`
MimeType string `xml:"mimeType,attr"`
Codecs string `xml:"codecs,attr"`
Lang string `xml:"lang,attr"`
Label string `xml:"label,attr"`
SegmentTemplate struct {
Text string `xml:",chardata"`
Media string `xml:"media,attr"`
Initialization string `xml:"initialization,attr"`
Duration string `xml:"duration,attr"`
StartNumber string `xml:"startNumber,attr"`
Timescale string `xml:"timescale,attr"`
} `xml:"SegmentTemplate"`
ContentProtection []struct {
Text string `xml:",chardata"`
SchemeIdUri string `xml:"schemeIdUri,attr"`
Value string `xml:"value,attr"`
DefaultKID string `xml:"default_KID,attr"`
Pssh string `xml:"pssh"`
} `xml:"ContentProtection"`
Representation []struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
Bandwidth string `xml:"bandwidth,attr"`
Width string `xml:"width,attr"`
Height string `xml:"height,attr"`
FrameRate string `xml:"frameRate,attr"`
AudioSamplingRate string `xml:"audioSamplingRate,attr"`
ContentProtection []struct {
Text string `xml:",chardata"`
SchemeIdUri string `xml:"schemeIdUri,attr"`
Value string `xml:"value,attr"`
DefaultKID string `xml:"default_KID,attr"`
Cenc string `xml:"cenc,attr"`
Pssh struct {
Text string `xml:",chardata"`
Cenc string `xml:"cenc,attr"`
} `xml:"pssh"`
} `xml:"ContentProtection"`
} `xml:"Representation"`
AudioChannelConfiguration struct {
Text string `xml:",chardata"`
SchemeIdUri string `xml:"schemeIdUri,attr"`
Value string `xml:"value,attr"`
} `xml:"AudioChannelConfiguration"`
} `xml:"AdaptationSet"`
} `xml:"Period"`
}
var mpdPlaylist mpd
if err := xml.NewDecoder(r).Decode(&mpdPlaylist); err != nil {
return nil, err
}
const widevineSchemeIdURI = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
for _, adaptionSet := range mpdPlaylist.Period.AdaptationSet {
for _, protection := range adaptionSet.ContentProtection {
if protection.SchemeIdUri == widevineSchemeIdURI && len(protection.Pssh) > 0 {
return base64.StdEncoding.DecodeString(protection.Pssh)
}
}
}
for _, adaptionSet := range mpdPlaylist.Period.AdaptationSet {
for _, representation := range adaptionSet.Representation {
for _, protection := range representation.ContentProtection {
if protection.SchemeIdUri == widevineSchemeIdURI && len(protection.Pssh.Text) > 0 {
return base64.StdEncoding.DecodeString(protection.Pssh.Text)
}
}
}
}
return nil, errors.New("no init data found")
}
// This function retrieves certificate data from a given license server.
func GetCertData(client *http.Client, licenseURL string) ([]byte, error) {
response, err := client.Post(licenseURL, "application/x-www-form-urlencoded", bytes.NewReader([]byte{0x08, 0x04}))
if err != nil {
return nil, err
}
defer response.Body.Close()
return io.ReadAll(response.Body)
}

5729
widevine/wv_proto2.pb.go Normal file

File diff suppressed because it is too large Load Diff