diff --git a/account.go b/account.go new file mode 100644 index 0000000..d22eba5 --- /dev/null +++ b/account.go @@ -0,0 +1,29 @@ +package crunchyroll + +import "time" + +// Account contains information about a crunchyroll account. +type Account struct { + AccountID string `json:"account_id"` + ExternalID string `json:"external_id"` + EmailVerified bool `json:"email_verified"` + Created time.Time `json:"created"` + + Avatar string `json:"avatar"` + CrBetaOptIn bool `json:"cr_beta_opt_in"` + Email string `json:"email"` + MatureContentFlagManga string `json:"mature_content_flag_manga"` + MaturityRating string `json:"maturity_rating"` + OptOutAndroidInAppMarketing bool `json:"opt_out_android_in_app_marketing"` + OptOutFreeTrials bool `json:"opt_out_free_trials"` + OptOutNewMediaQueueUpdates bool `json:"opt_out_new_media_queue_updates"` + OptOutNewsletters bool `json:"opt_out_newsletters"` + OptOutPmUpdates bool `json:"opt_out_pm_updates"` + OptOutPromotionalUpdates bool `json:"opt_out_promotional_updates"` + OptOutQueueUpdates bool `json:"opt_out_queue_updates"` + OptOutStoreDeals bool `json:"opt_out_store_deals"` + PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"` + PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"` + QaUser bool `json:"qa_user"` + Username string `json:"username"` +} diff --git a/category.go b/category.go new file mode 100644 index 0000000..58855fc --- /dev/null +++ b/category.go @@ -0,0 +1,43 @@ +package crunchyroll + +// Category contains all information about a category. +type Category struct { + Category string `json:"tenant_category"` + + SubCategories []struct { + Category string `json:"tenant_category"` + ParentCategory string `json:"parent_category"` + + Localization struct { + Title string `json:"title"` + Description string `json:"description"` + Locale LOCALE `json:"locale"` + } `json:"localization"` + + Slug string `json:"slug"` + } `json:"sub_categories"` + + Images struct { + Background []struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"background"` + + Low []struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"low"` + } `json:"images"` + + Localization struct { + Title string `json:"title"` + Description string `json:"description"` + Locale LOCALE `json:"locale"` + } `json:"localization"` + + Slug string `json:"slug"` +} diff --git a/crunchyroll.go b/crunchyroll.go index dcfc375..d20dd20 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -30,6 +30,23 @@ const ( AR = "ar-SA" ) +// MediaType represents a media type. +type MediaType string + +const ( + SERIES MediaType = "series" + MOVIELISTING = "movie_listing" +) + +// SortType represents a sort type. +type SortType string + +const ( + POPULARITY SortType = "popularity" + NEWLYADDED = "newly_added" + ALPHABETICAL = "alphabetical" +) + type Crunchyroll struct { // Client is the http.Client to perform all requests over. Client *http.Client @@ -60,6 +77,30 @@ type Crunchyroll struct { cache bool } +// BrowseOptions represents options for browsing the crunchyroll catalog. +type BrowseOptions struct { + // Categories specifies the categories of the entries. + Categories []string `param:"categories"` + + // IsDubbed specifies whether the entries should be dubbed. + IsDubbed bool `param:"is_dubbed"` + + // IsSubbed specifies whether the entries should be subbed. + IsSubbed bool `param:"is_subbed"` + + // Simulcast specifies a particular simulcast season by id in which the entries have been aired. + Simulcast string `param:"season_tag"` + + // Sort specifies how the entries should be sorted. + Sort SortType `param:"sort_by"` + + // Start specifies the index from which the entries should be returned. + Start uint `param:"start"` + + // Type specifies the media type of the entries. + Type MediaType `param:"type"` +} + // LoginWithCredentials logs in via crunchyroll username or email and password. func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) { sessionIDEndpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?version=1.0&access_token=%s&device_type=%s&device_id=%s", @@ -446,3 +487,327 @@ func ParseBetaEpisodeURL(url string) (episodeId string, ok bool) { } return } + +// Browse browses the crunchyroll catalog filtered by the specified options and returns all found series and movies within the given limit. +func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m []*Movie, err error) { + query, err := encodeStructToQueryValues(options) + if err != nil { + return nil, nil, err + } + + browseEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/browse?%s&n=%d&locale=%s", + query, limit, c.Locale) + resp, err := c.request(browseEndpoint) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'browse' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// Categories returns all available categories and possible subcategories. +func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err error) { + tenantCategoriesEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/tenant_categories?include_subcategories=%t&locale=%s", + includeSubcategories, c.Locale) + resp, err := c.request(tenantCategoriesEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'tenant_categories' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + category := &Category{} + if err := decodeMapToStruct(item, category); err != nil { + return nil, err + } + + ca = append(ca, category) + } + + return ca, nil +} + +// Simulcasts returns all available simulcast seasons for the current locale. +func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) { + seasonListEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/season_list?locale=%s", c.Locale) + resp, err := c.request(seasonListEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'season_list' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + simulcast := &Simulcast{} + if err := decodeMapToStruct(item, simulcast); err != nil { + return nil, err + } + + s = append(s, simulcast) + } + + return s, nil +} + +// News returns the top and latest news from crunchyroll for the current locale within the given limits. +func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*News, err error) { + newsFeedEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/news_feed?top_news_n=%d&latest_news_n=%d&locale=%s", + topLimit, latestLimit, c.Locale) + resp, err := c.request(newsFeedEndpoint) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'news_feed' response: %w", err) + } + + topNews := jsonBody["top_news"].(map[string]interface{}) + for _, item := range topNews["items"].([]interface{}) { + topNews := &News{} + if err := decodeMapToStruct(item, topNews); err != nil { + return nil, nil, err + } + + t = append(t, topNews) + } + + latestNews := jsonBody["latest_news"].(map[string]interface{}) + for _, item := range latestNews["items"].([]interface{}) { + latestNews := &News{} + if err := decodeMapToStruct(item, latestNews); err != nil { + return nil, nil, err + } + + l = append(l, latestNews) + } + + return t, l, nil +} + +// Recommendations returns series and movie recommendations from crunchyroll based on the currently logged in account within the given limit. +func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err error) { + recommendationsEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/recommendations?n=%d&locale=%s", + c.Config.AccountID, limit, c.Locale) + resp, err := c.request(recommendationsEndpoint) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'recommendations' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// UpNext returns the episodes that are up next based on the currently logged in account within the given limit. +func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) { + upNextAccountEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/up_next_account?n=%d&locale=%s", + c.Config.AccountID, limit, c.Locale) + resp, err := c.request(upNextAccountEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'up_next_account' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + panel := item.(map[string]interface{})["panel"] + + episode := &Episode{ + crunchy: c, + } + if err := decodeMapToStruct(panel, episode); err != nil { + return nil, err + } + + e = append(e, episode) + } + + return e, nil +} + +// SimilarTo returns similar series and movies according to crunchyroll to the one specified by id within the given limit. +func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie, err error) { + similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s", + c.Config.AccountID, id, limit, c.Locale) + resp, err := c.request(similarToEndpoint) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'similar_to' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// WatchHistory returns the history of watched episodes based on the currently logged in account from the given page with the given size. +func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, err error) { + watchHistoryEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/watch-history/%s?page=%d&page_size=%d&locale=%s", + c.Config.AccountID, page, size, c.Locale) + resp, err := c.request(watchHistoryEndpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'watch-history' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + panel := item.(map[string]interface{})["panel"] + + episode := &Episode{ + crunchy: c, + } + if err := decodeMapToStruct(panel, episode); err != nil { + return nil, err + } + + historyEpisode := &HistoryEpisode{ + Episode: episode, + } + if err := decodeMapToStruct(item, historyEpisode); err != nil { + return nil, err + } + + e = append(e, historyEpisode) + } + + return e, nil +} + +// Account returns information about the currently logged in crunchyroll account. +func (c *Crunchyroll) Account() (*Account, error) { + resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + account := &Account{} + + if err = json.NewDecoder(resp.Body).Decode(&account); err != nil { + return nil, fmt.Errorf("failed to parse 'me' response: %w", err) + } + + resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&account); err != nil { + return nil, fmt.Errorf("failed to parse 'profile' response: %w", err) + } + + return account, nil +} diff --git a/episode.go b/episode.go index a25844c..de38c65 100644 --- a/episode.go +++ b/episode.go @@ -75,6 +75,17 @@ type Episode struct { StreamID string } +// HistoryEpisode contains additional information about an episode if the account has watched or started to watch the episode. +type HistoryEpisode struct { + *Episode + + DatePlayed time.Time `json:"date_played"` + ParentID string `json:"parent_id"` + ParentType MediaType `json:"parent_type"` + Playhead uint `json:"playhead"` + FullyWatched bool `json:"fully_watched"` +} + // EpisodeFromID returns an episode by its api id. func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) { resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s", diff --git a/news.go b/news.go new file mode 100644 index 0000000..d90dd65 --- /dev/null +++ b/news.go @@ -0,0 +1,11 @@ +package crunchyroll + +// News contains all information about a news. +type News struct { + Title string `json:"title"` + Link string `json:"link"` + Image string `json:"image"` + Creator string `json:"creator"` + PublishDate string `json:"publish_date"` + Description string `json:"description"` +} diff --git a/simulcast.go b/simulcast.go new file mode 100644 index 0000000..d02f348 --- /dev/null +++ b/simulcast.go @@ -0,0 +1,13 @@ +package crunchyroll + +// Simulcast contains all information about a simulcast season. +type Simulcast struct { + ID string `json:"id"` + + Localization struct { + Title string `json:"title"` + + // appears to be always an empty string. + Description string `json:"description"` + } `json:"localization"` +} diff --git a/utils.go b/utils.go index a3d4191..d32d39b 100644 --- a/utils.go +++ b/utils.go @@ -2,6 +2,10 @@ package crunchyroll import ( "encoding/json" + "fmt" + "net/url" + "reflect" + "strings" ) func decodeMapToStruct(m interface{}, s interface{}) error { @@ -23,3 +27,46 @@ func regexGroups(parsed [][]string, subexpNames ...string) map[string]string { } return groups } + +func encodeStructToQueryValues(s interface{}) (string, error) { + values := make(url.Values) + v := reflect.ValueOf(s) + + for i := 0; i < v.Type().NumField(); i++ { + + // don't include parameters with default or without values in the query to avoid corruption of the API response. + switch v.Field(i).Kind() { + case reflect.Slice, reflect.String: + if v.Field(i).Len() == 0 { + continue + } + case reflect.Bool: + if !v.Field(i).Bool() { + continue + } + case reflect.Uint: + if v.Field(i).Uint() == 0 { + continue + } + } + + key := v.Type().Field(i).Tag.Get("param") + var val string + + if v.Field(i).Kind() == reflect.Slice { + var items []string + + for _, i := range v.Field(i).Interface().([]string) { + items = append(items, i) + } + + val = strings.Join(items, ",") + } else { + val = fmt.Sprint(v.Field(i).Interface()) + } + + values.Add(key, val) + } + + return values.Encode(), nil +}