diff --git a/client/client.go b/client/client.go deleted file mode 100644 index 97e63de..0000000 --- a/client/client.go +++ /dev/null @@ -1,224 +0,0 @@ -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 -}