package client import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "lukechampine.com/frand" ) 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, huluGUID string) Client { return Client{c, huluSession, huluGUID} } // Returns a Client object using a default HTTP client with a timeout of 10s. func NewDefaultClient(huluSession, huluGUID string) Client { return NewClient(&http.Client{ Timeout: 10 * time.Second, }, huluSession, huluGUID) } // 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 // 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() string { randChars := func(n int) (s string) { c := []byte("ABCDEF0123456789") for i := 0; i < n; 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 }