From ec872d8c86dbfca2757f8ccaa0fa754e21c13703 Mon Sep 17 00:00:00 2001 From: bytedream Date: Tue, 21 Jun 2022 21:15:49 +0200 Subject: [PATCH] Move functions into their own, separate files & add docs --- account.go | 30 +++ category.go | 48 +++- crunchylists.go | 19 ++ crunchyroll.go | 624 ----------------------------------------------- news.go | 46 +++- parse.go | 69 ++++++ search.go | 193 +++++++++++++++ simulcast.go | 32 +++ suggestions.go | 82 +++++++ video.go | 43 ++++ watch_history.go | 45 ++++ watchlist.go | 88 +++++++ 12 files changed, 681 insertions(+), 638 deletions(-) create mode 100644 parse.go create mode 100644 search.go create mode 100644 suggestions.go create mode 100644 watch_history.go diff --git a/account.go b/account.go index 87a6ef8..f2d6a70 100644 --- a/account.go +++ b/account.go @@ -3,10 +3,40 @@ package crunchyroll import ( "bytes" "encoding/json" + "fmt" "net/http" "time" ) +// 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", http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + account := &Account{ + crunchy: c, + } + + 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", http.MethodGet) + 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 +} + // Account contains information about a crunchyroll account. type Account struct { crunchy *Crunchyroll diff --git a/category.go b/category.go index 58855fc..eccefff 100644 --- a/category.go +++ b/category.go @@ -1,5 +1,38 @@ package crunchyroll +import ( + "encoding/json" + "fmt" + "net/http" +) + +// 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, http.MethodGet) + 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 +} + // Category contains all information about a category. type Category struct { Category string `json:"tenant_category"` @@ -18,19 +51,8 @@ type Category struct { } `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"` + Background []Image `json:"background"` + Low []Image `json:"low"` } `json:"images"` Localization struct { diff --git a/crunchylists.go b/crunchylists.go index 1117dd7..a2a7a1f 100644 --- a/crunchylists.go +++ b/crunchylists.go @@ -8,6 +8,25 @@ import ( "time" ) +func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale) + resp, err := c.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + crunchyLists := &CrunchyLists{ + crunchy: c, + } + json.NewDecoder(resp.Body).Decode(crunchyLists) + for _, item := range crunchyLists.Items { + item.crunchy = c + } + + return crunchyLists, nil +} + type CrunchyLists struct { crunchy *Crunchyroll diff --git a/crunchyroll.go b/crunchyroll.go index 5de3ab2..a83415c 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -8,8 +8,6 @@ import ( "io" "net/http" "net/url" - "regexp" - "strconv" "strings" ) @@ -38,30 +36,6 @@ const ( MediaTypeMovie = "movie_listing" ) -// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after. -type BrowseSortType string - -const ( - BrowseSortPopularity BrowseSortType = "popularity" - BrowseSortNewlyAdded = "newly_added" - BrowseSortAlphabetical = "alphabetical" -) - -// WatchlistLanguageType represents a filter type to filter Crunchyroll.WatchList entries after. -type WatchlistLanguageType int - -const ( - WatchlistLanguageSubbed WatchlistLanguageType = iota + 1 - WatchlistLanguageDubbed -) - -type WatchlistContentType string - -const ( - WatchlistContentSeries WatchlistContentType = "series" - WatchlistContentMovies = "movie_listing" -) - type Crunchyroll struct { // Client is the http.Client to perform all requests over. Client *http.Client @@ -95,30 +69,6 @@ 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 BrowseSortType `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"` -} - type loginResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -377,577 +327,3 @@ func (c *Crunchyroll) IsCaching() bool { func (c *Crunchyroll) SetCaching(caching bool) { c.cache = caching } - -// Search searches a query and returns all found series and movies within the given limit. -func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, err error) { - searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s", - query, limit, c.Locale) - resp, err := c.request(searchEndpoint, http.MethodGet) - 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 'search' response: %w", err) - } - - for _, item := range jsonBody["items"].([]interface{}) { - item := item.(map[string]interface{}) - if item["total"].(float64) > 0 { - switch item["type"] { - case "series": - for _, series := range item["items"].([]interface{}) { - series2 := &Series{ - crunchy: c, - } - if err := decodeMapToStruct(series, series2); err != nil { - return nil, nil, err - } - if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil { - return nil, nil, err - } - - s = append(s, series2) - } - case "movie_listing": - for _, movie := range item["items"].([]interface{}) { - movie2 := &Movie{ - crunchy: c, - } - if err := decodeMapToStruct(movie, movie2); err != nil { - return nil, nil, err - } - - m = append(m, movie2) - } - } - } - } - - return s, m, nil -} - -// FindVideoByName finds a Video (Season or Movie) by its name. -// Use this in combination with ParseVideoURL and hand over the corresponding results -// to this function. -// -// Deprecated: Use Search instead. The first result sometimes isn't the correct one -// so this function is inaccurate in some cases. -// See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information. -func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) { - s, m, err := c.Search(seriesName, 1) - if err != nil { - return nil, err - } - - if len(s) > 0 { - return s[0], nil - } else if len(m) > 0 { - return m[0], nil - } - return nil, fmt.Errorf("no series or movie could be found") -} - -// FindEpisodeByName finds an episode by its crunchyroll series name and episode title. -// Use this in combination with ParseEpisodeURL and hand over the corresponding results -// to this function. -func (c *Crunchyroll) FindEpisodeByName(seriesName, episodeTitle string) ([]*Episode, error) { - series, _, err := c.Search(seriesName, 5) - if err != nil { - return nil, err - } - - var matchingEpisodes []*Episode - for _, s := range series { - seasons, err := s.Seasons() - if err != nil { - return nil, err - } - - for _, season := range seasons { - episodes, err := season.Episodes() - if err != nil { - return nil, err - } - for _, episode := range episodes { - if episode.SlugTitle == episodeTitle { - matchingEpisodes = append(matchingEpisodes, episode) - } - } - } - } - - return matchingEpisodes, nil -} - -// ParseVideoURL tries to extract the crunchyroll series / movie name out of the given url. -// -// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaSeriesURL -// if possible since beta url are always safe to use. -// The method will stay in the library until only beta urls are supported by crunchyroll itself. -func ParseVideoURL(url string) (seriesName string, ok bool) { - pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P[^/]+)(/videos)?/?$`) - if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { - groups := regexGroups(urlMatch, pattern.SubexpNames()...) - seriesName = groups["series"] - - if seriesName != "" { - ok = true - } - } - return -} - -// ParseEpisodeURL tries to extract the crunchyroll series name, title, episode number and web id out of the given crunchyroll url -// Note that the episode number can be misleading. For example if an episode has the episode number 23.5 (slime isekai) -// the episode number will be 235. -// -// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaEpisodeURL -// if possible since beta url are always safe to use. -// The method will stay in the library until only beta urls are supported by crunchyroll itself. -func ParseEpisodeURL(url string) (seriesName, title string, episodeNumber int, webId int, ok bool) { - pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P[^/]+)/episode-(?P\d+)-(?P.+)-(?P<webId>\d+).*`) - if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { - groups := regexGroups(urlMatch, pattern.SubexpNames()...) - seriesName = groups["series"] - episodeNumber, _ = strconv.Atoi(groups["number"]) - title = groups["title"] - webId, _ = strconv.Atoi(groups["webId"]) - - if seriesName != "" && title != "" && webId != 0 { - ok = true - } - } - return -} - -// ParseBetaSeriesURL tries to extract the season id of the given crunchyroll beta url, pointing to a season. -func ParseBetaSeriesURL(url string) (seasonId string, ok bool) { - pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?series/(?P<seasonId>\w+).*`) - if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { - groups := regexGroups(urlMatch, pattern.SubexpNames()...) - seasonId = groups["seasonId"] - ok = true - } - return -} - -// ParseBetaEpisodeURL tries to extract the episode id of the given crunchyroll beta url, pointing to an episode. -func ParseBetaEpisodeURL(url string) (episodeId string, ok bool) { - pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?watch/(?P<episodeId>\w+).*`) - if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { - groups := regexGroups(urlMatch, pattern.SubexpNames()...) - episodeId = groups["episodeId"] - ok = true - } - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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, http.MethodGet) - 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 -} - -type WatchlistOptions struct { - // OrderAsc specified whether the results should be order ascending or descending. - OrderAsc bool - - // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned. - OnlyFavorites bool - - // LanguageType specifies whether returning episodes should be only subbed or dubbed. - LanguageType WatchlistLanguageType - - // ContentType specified whether returning videos should only be series episodes or movies. - // But tbh all movies I've searched on crunchy were flagged as series too, so this - // parameter is kinda useless. - ContentType WatchlistContentType -} - -func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) { - values := url.Values{} - if options.OrderAsc { - values.Set("order", "asc") - } else { - values.Set("order", "desc") - } - if options.OnlyFavorites { - values.Set("only_favorites", "true") - } - switch options.LanguageType { - case WatchlistLanguageSubbed: - values.Set("is_subbed", "true") - case WatchlistLanguageDubbed: - values.Set("is_dubbed", "true") - } - values.Set("n", strconv.Itoa(int(limit))) - values.Set("locale", string(c.Locale)) - - endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode()) - resp, err := c.request(endpoint, http.MethodGet) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var jsonBody map[string]interface{} - json.NewDecoder(resp.Body).Decode(&jsonBody) - - var watchlistEntries []*WatchlistEntry - if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil { - return nil, err - } - - for _, entry := range watchlistEntries { - switch entry.Panel.Type { - case WatchlistEntryEpisode: - entry.Panel.EpisodeMetadata.crunchy = c - case WatchlistEntrySeries: - entry.Panel.SeriesMetadata.crunchy = c - } - } - - return watchlistEntries, nil -} - -func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) { - endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale) - resp, err := c.request(endpoint, http.MethodGet) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - crunchyLists := &CrunchyLists{ - crunchy: c, - } - json.NewDecoder(resp.Body).Decode(crunchyLists) - for _, item := range crunchyLists.Items { - item.crunchy = c - } - - return crunchyLists, 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", http.MethodGet) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - account := &Account{ - crunchy: c, - } - - 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", http.MethodGet) - 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/news.go b/news.go index d90dd65..074dc0e 100644 --- a/news.go +++ b/news.go @@ -1,6 +1,50 @@ package crunchyroll -// News contains all information about a news. +import ( + "encoding/json" + "fmt" + "net/http" +) + +// 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, http.MethodGet) + 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 +} + +// News contains all information about news. type News struct { Title string `json:"title"` Link string `json:"link"` diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..392e971 --- /dev/null +++ b/parse.go @@ -0,0 +1,69 @@ +package crunchyroll + +import ( + "regexp" + "strconv" +) + +// ParseBetaSeriesURL tries to extract the season id of the given crunchyroll beta url, pointing to a season. +func ParseBetaSeriesURL(url string) (seasonId string, ok bool) { + pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?series/(?P<seasonId>\w+).*`) + if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { + groups := regexGroups(urlMatch, pattern.SubexpNames()...) + seasonId = groups["seasonId"] + ok = true + } + return +} + +// ParseBetaEpisodeURL tries to extract the episode id of the given crunchyroll beta url, pointing to an episode. +func ParseBetaEpisodeURL(url string) (episodeId string, ok bool) { + pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?watch/(?P<episodeId>\w+).*`) + if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { + groups := regexGroups(urlMatch, pattern.SubexpNames()...) + episodeId = groups["episodeId"] + ok = true + } + return +} + +// ParseVideoURL tries to extract the crunchyroll series / movie name out of the given url. +// +// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaSeriesURL +// if possible since beta url are always safe to use. +// The method will stay in the library until only beta urls are supported by crunchyroll itself. +func ParseVideoURL(url string) (seriesName string, ok bool) { + pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)(/videos)?/?$`) + if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { + groups := regexGroups(urlMatch, pattern.SubexpNames()...) + seriesName = groups["series"] + + if seriesName != "" { + ok = true + } + } + return +} + +// ParseEpisodeURL tries to extract the crunchyroll series name, title, episode number and web id out of the given crunchyroll url +// Note that the episode number can be misleading. For example if an episode has the episode number 23.5 (slime isekai) +// the episode number will be 235. +// +// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaEpisodeURL +// if possible since beta url are always safe to use. +// The method will stay in the library until only beta urls are supported by crunchyroll itself. +func ParseEpisodeURL(url string) (seriesName, title string, episodeNumber int, webId int, ok bool) { + pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)/episode-(?P<number>\d+)-(?P<title>.+)-(?P<webId>\d+).*`) + if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 { + groups := regexGroups(urlMatch, pattern.SubexpNames()...) + seriesName = groups["series"] + episodeNumber, _ = strconv.Atoi(groups["number"]) + title = groups["title"] + webId, _ = strconv.Atoi(groups["webId"]) + + if seriesName != "" && title != "" && webId != 0 { + ok = true + } + } + return +} diff --git a/search.go b/search.go new file mode 100644 index 0000000..d7f6e76 --- /dev/null +++ b/search.go @@ -0,0 +1,193 @@ +package crunchyroll + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after. +type BrowseSortType string + +const ( + BrowseSortPopularity BrowseSortType = "popularity" + BrowseSortNewlyAdded = "newly_added" + BrowseSortAlphabetical = "alphabetical" +) + +// 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 BrowseSortType `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"` +} + +// 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, http.MethodGet) + 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 +} + +// FindVideoByName finds a Video (Season or Movie) by its name. +// Use this in combination with ParseVideoURL and hand over the corresponding results +// to this function. +// +// Deprecated: Use Search instead. The first result sometimes isn't the correct one +// so this function is inaccurate in some cases. +// See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information. +func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) { + s, m, err := c.Search(seriesName, 1) + if err != nil { + return nil, err + } + + if len(s) > 0 { + return s[0], nil + } else if len(m) > 0 { + return m[0], nil + } + return nil, fmt.Errorf("no series or movie could be found") +} + +// FindEpisodeByName finds an episode by its crunchyroll series name and episode title. +// Use this in combination with ParseEpisodeURL and hand over the corresponding results +// to this function. +func (c *Crunchyroll) FindEpisodeByName(seriesName, episodeTitle string) ([]*Episode, error) { + series, _, err := c.Search(seriesName, 5) + if err != nil { + return nil, err + } + + var matchingEpisodes []*Episode + for _, s := range series { + seasons, err := s.Seasons() + if err != nil { + return nil, err + } + + for _, season := range seasons { + episodes, err := season.Episodes() + if err != nil { + return nil, err + } + for _, episode := range episodes { + if episode.SlugTitle == episodeTitle { + matchingEpisodes = append(matchingEpisodes, episode) + } + } + } + } + + return matchingEpisodes, nil +} + +// Search searches a query and returns all found series and movies within the given limit. +func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, err error) { + searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s", + query, limit, c.Locale) + resp, err := c.request(searchEndpoint, http.MethodGet) + 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 'search' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + item := item.(map[string]interface{}) + if item["total"].(float64) > 0 { + switch item["type"] { + case "series": + for _, series := range item["items"].([]interface{}) { + series2 := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(series, series2); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil { + return nil, nil, err + } + + s = append(s, series2) + } + case "movie_listing": + for _, movie := range item["items"].([]interface{}) { + movie2 := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(movie, movie2); err != nil { + return nil, nil, err + } + + m = append(m, movie2) + } + } + } + } + + return s, m, nil +} diff --git a/simulcast.go b/simulcast.go index d02f348..e77d2f8 100644 --- a/simulcast.go +++ b/simulcast.go @@ -1,5 +1,37 @@ package crunchyroll +import ( + "encoding/json" + "fmt" + "net/http" +) + +// 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, http.MethodGet) + 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 +} + // Simulcast contains all information about a simulcast season. type Simulcast struct { ID string `json:"id"` diff --git a/suggestions.go b/suggestions.go new file mode 100644 index 0000000..d6bd770 --- /dev/null +++ b/suggestions.go @@ -0,0 +1,82 @@ +package crunchyroll + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// 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, http.MethodGet) + 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, http.MethodGet) + 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 +} diff --git a/video.go b/video.go index 3a67f5c..2d76499 100644 --- a/video.go +++ b/video.go @@ -206,6 +206,49 @@ func (s *Series) RemoveFromWatchlist() error { return err } +// Similar returns similar series and movies to the current series within the given limit. +func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) { + similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s", + s.crunchy.Config.AccountID, s.ID, limit, s.crunchy.Locale) + resp, err := s.crunchy.request(similarToEndpoint, http.MethodGet) + 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: s.crunchy, + } + 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 + } + + ss = append(ss, series) + case "movie_listing": + movie := &Movie{ + crunchy: s.crunchy, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + return +} + // Seasons returns all seasons of a series. func (s *Series) Seasons() (seasons []*Season, err error) { if s.children != nil { diff --git a/watch_history.go b/watch_history.go new file mode 100644 index 0000000..d9ce265 --- /dev/null +++ b/watch_history.go @@ -0,0 +1,45 @@ +package crunchyroll + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// 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, http.MethodGet) + 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 +} diff --git a/watchlist.go b/watchlist.go index 73fbb3d..b4db34d 100644 --- a/watchlist.go +++ b/watchlist.go @@ -1,5 +1,93 @@ package crunchyroll +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// WatchlistLanguageType represents a filter type to filter Crunchyroll.Watchlist entries after sub or dub. +type WatchlistLanguageType int + +const ( + WatchlistLanguageSubbed WatchlistLanguageType = iota + 1 + WatchlistLanguageDubbed +) + +// WatchlistContentType represents a filter type to filter Crunchyroll.Watchlist entries if they're series or movies. +type WatchlistContentType string + +const ( + WatchlistContentSeries WatchlistContentType = "series" + WatchlistContentMovies = "movie_listing" +) + +// WatchlistOptions represents options for receiving the user watchlist. +type WatchlistOptions struct { + // OrderAsc specified whether the results should be order ascending or descending. + OrderAsc bool + + // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned. + OnlyFavorites bool + + // LanguageType specifies whether returning episodes should be only subbed or dubbed. + LanguageType WatchlistLanguageType + + // ContentType specified whether returning videos should only be series episodes or movies. + // But tbh all movies I've searched on crunchy were flagged as series too, so this + // parameter is kinda useless. + ContentType WatchlistContentType +} + +// Watchlist returns the watchlist entries for the currently logged in user. +func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) { + values := url.Values{} + if options.OrderAsc { + values.Set("order", "asc") + } else { + values.Set("order", "desc") + } + if options.OnlyFavorites { + values.Set("only_favorites", "true") + } + switch options.LanguageType { + case WatchlistLanguageSubbed: + values.Set("is_subbed", "true") + case WatchlistLanguageDubbed: + values.Set("is_dubbed", "true") + } + values.Set("n", strconv.Itoa(int(limit))) + values.Set("locale", string(c.Locale)) + + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode()) + resp, err := c.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + json.NewDecoder(resp.Body).Decode(&jsonBody) + + var watchlistEntries []*WatchlistEntry + if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil { + return nil, err + } + + for _, entry := range watchlistEntries { + switch entry.Panel.Type { + case WatchlistEntryEpisode: + entry.Panel.EpisodeMetadata.crunchy = c + case WatchlistEntrySeries: + entry.Panel.SeriesMetadata.crunchy = c + } + } + + return watchlistEntries, nil +} + // WatchlistEntry contains information about an entry on the watchlist. type WatchlistEntry struct { Panel Panel `json:"panel"`