From c5f2b55f346d2e45a6cf09ffa8750c915a180970 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 00:22:38 +0200 Subject: [PATCH 01/32] Add watchlist endpoint, add new request method & change SortType name and consts --- crunchyroll.go | 93 +++++++++++++++++++++++++++++++++++++++++++---- episode.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++---- video.go | 21 +++++++++++ 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/crunchyroll.go b/crunchyroll.go index 38b889b..7618fdf 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -38,13 +38,28 @@ const ( MOVIELISTING = "movie_listing" ) -// SortType represents a sort type. -type SortType string +// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after. +type BrowseSortType string const ( - POPULARITY SortType = "popularity" - NEWLYADDED = "newly_added" - ALPHABETICAL = "alphabetical" + 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 { @@ -95,7 +110,7 @@ type BrowseOptions struct { Simulcast string `param:"season_tag"` // Sort specifies how the entries should be sorted. - Sort SortType `param:"sort_by"` + Sort BrowseSortType `param:"sort_by"` // Start specifies the index from which the entries should be returned. Start uint `param:"start"` @@ -329,6 +344,10 @@ func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, e if err != nil { return nil, err } + return c.requestFull(req) +} + +func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) { req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) return request(req, c.Client) @@ -812,6 +831,68 @@ func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, e 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 +} + // 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) diff --git a/episode.go b/episode.go index 0405e0e..e99bd12 100644 --- a/episode.go +++ b/episode.go @@ -1,6 +1,7 @@ package crunchyroll import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -39,11 +40,14 @@ type Episode struct { NextEpisodeID string `json:"next_episode_id"` NextEpisodeTitle string `json:"next_episode_title"` - HDFlag bool `json:"hd_flag"` - IsMature bool `json:"is_mature"` - MatureBlocked bool `json:"mature_blocked"` + HDFlag bool `json:"hd_flag"` + MaturityRatings []string `json:"maturity_ratings"` + IsMature bool `json:"is_mature"` + MatureBlocked bool `json:"mature_blocked"` - EpisodeAirDate time.Time `json:"episode_air_date"` + EpisodeAirDate time.Time `json:"episode_air_date"` + FreeAvailableDate time.Time `json:"free_available_date"` + PremiumAvailableDate time.Time `json:"premium_available_date"` IsSubbed bool `json:"is_subbed"` IsDubbed bool `json:"is_dubbed"` @@ -52,8 +56,9 @@ type Episode struct { SeoDescription string `json:"seo_description"` SeasonTags []string `json:"season_tags"` - AvailableOffline bool `json:"available_offline"` - Slug string `json:"slug"` + AvailableOffline bool `json:"available_offline"` + MediaType MediaType `json:"media_type"` + Slug string `json:"slug"` Images struct { Thumbnail [][]struct { @@ -87,6 +92,62 @@ type HistoryEpisode struct { FullyWatched bool `json:"fully_watched"` } +// WatchlistEntryType specifies which type a watchlist entry has. +type WatchlistEntryType string + +const ( + WATCHLISTENTRYEPISODE = "episode" + WATCHLISTENTRYSERIES = "series" +) + +// WatchlistEntry contains information about an entry on the watchlist. +type WatchlistEntry struct { + Panel struct { + Title string `json:"title"` + PromoTitle string `json:"promo_title"` + Slug string `json:"slug"` + Playback string `json:"playback"` + PromoDescription string `json:"promo_description"` + Images struct { + Thumbnail [][]struct { + Height int `json:"height"` + Source string `json:"source"` + Type string `json:"type"` + Width int `json:"width"` + } `json:"thumbnail"` + PosterTall [][]struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"poster_tall"` + PosterWide [][]struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"poster_wide"` + } `json:"images"` + ID string `json:"id"` + Description string `json:"description"` + ChannelID string `json:"channel_id"` + Type WatchlistEntryType `json:"type"` + ExternalID string `json:"external_id"` + SlugTitle string `json:"slug_title"` + // not null if Type is WATCHLISTENTRYEPISODE + EpisodeMetadata *Episode `json:"episode_metadata"` + // not null if Type is WATCHLISTENTRYSERIES + SeriesMetadata *Series `json:"series_metadata"` + } + + New bool `json:"new"` + NewContent bool `json:"new_content"` + IsFavorite bool `json:"is_favorite"` + NeverWatched bool `json:"never_watched"` + CompleteStatus bool `json:"complete_status"` + Playahead uint `json:"playahead"` +} + // 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", @@ -122,6 +183,30 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) { return episode, nil } +// AddToWatchlist adds the current episode to the watchlist. +// There is currently a bug, or as I like to say in context of the crunchyroll api, feature, +// that only series and not individual episode can be added to the watchlist. Even though +// I somehow got an episode to my watchlist on the crunchyroll website, it never worked with the +// api here. So this function actually adds the whole series to the watchlist. +func (e *Episode) AddToWatchlist() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", e.crunchy.Config.AccountID, e.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"content_id": e.SeriesID}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = e.crunchy.requestFull(req) + return err +} + +// RemoveFromWatchlist removes the current episode from the watchlist. +func (e *Episode) RemoveFromWatchlist() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", e.crunchy.Config.AccountID, e.SeriesID, e.crunchy.Locale) + _, err := e.crunchy.request(endpoint, http.MethodDelete) + return err +} + // AudioLocale returns the audio locale of the episode. // Every episode in a season (should) have the same audio locale, // so if you want to get the audio locale of a season, just call diff --git a/video.go b/video.go index 365f954..42efacd 100644 --- a/video.go +++ b/video.go @@ -1,6 +1,7 @@ package crunchyroll import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -193,6 +194,26 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) { return series, nil } +// AddToWatchlist adds the current episode to the watchlist. +func (s *Series) AddToWatchlist() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", s.crunchy.Config.AccountID, s.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"content_id": s.ID}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = s.crunchy.requestFull(req) + return err +} + +// RemoveFromWatchlist removes the current episode from the watchlist. +func (s *Series) RemoveFromWatchlist() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", s.crunchy.Config.AccountID, s.ID, s.crunchy.Locale) + _, err := s.crunchy.request(endpoint, http.MethodDelete) + return err +} + // Seasons returns all seasons of a series. func (s *Series) Seasons() (seasons []*Season, err error) { if s.children != nil { From 5709012dfe5f625426ba28b99b52720a6ff32b8f Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 00:36:27 +0200 Subject: [PATCH 02/32] Add custom error for internal request --- crunchyroll.go | 10 +++++----- episode.go | 9 +++++---- error.go | 17 +++++++++++++++++ video.go | 2 ++ 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 error.go diff --git a/crunchyroll.go b/crunchyroll.go index 7618fdf..4c29ab4 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -314,25 +314,25 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) { var errMap map[string]any if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil { - return nil, fmt.Errorf("invalid json response: %w", err) + return nil, &RequestError{Response: resp, Message: fmt.Sprintf("invalid json response: %w", err)} } if val, ok := errMap["error"]; ok { if errorAsString, ok := val.(string); ok { if code, ok := errMap["code"].(string); ok { - return nil, fmt.Errorf("error for endpoint %s (%d): %s - %s", req.URL.String(), resp.StatusCode, errorAsString, code) + return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errorAsString, code)} } - return nil, fmt.Errorf("error for endpoint %s (%d): %s", req.URL.String(), resp.StatusCode, errorAsString) + return nil, &RequestError{Response: resp, Message: errorAsString} } else if errorAsBool, ok := val.(bool); ok && errorAsBool { if msg, ok := errMap["message"].(string); ok { - return nil, fmt.Errorf("error for endpoint %s (%d): %s", req.URL.String(), resp.StatusCode, msg) + return nil, &RequestError{Response: resp, Message: msg} } } } } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("error for endpoint %s: %s", req.URL.String(), resp.Status) + return nil, &RequestError{Response: resp, Message: resp.Status} } } return resp, err diff --git a/episode.go b/episode.go index e99bd12..d0e9b14 100644 --- a/episode.go +++ b/episode.go @@ -184,10 +184,10 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) { } // AddToWatchlist adds the current episode to the watchlist. -// There is currently a bug, or as I like to say in context of the crunchyroll api, feature, -// that only series and not individual episode can be added to the watchlist. Even though -// I somehow got an episode to my watchlist on the crunchyroll website, it never worked with the -// api here. So this function actually adds the whole series to the watchlist. +// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before. +// There is currently a bug, or as I like to say in context of the crunchyroll api, feature, that only series and not +// individual episode can be added to the watchlist. Even though I somehow got an episode to my watchlist on the +// crunchyroll website, it never worked with the api here. So this function actually adds the whole series to the watchlist. func (e *Episode) AddToWatchlist() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", e.crunchy.Config.AccountID, e.crunchy.Locale) body, _ := json.Marshal(map[string]string{"content_id": e.SeriesID}) @@ -201,6 +201,7 @@ func (e *Episode) AddToWatchlist() error { } // RemoveFromWatchlist removes the current episode from the watchlist. +// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before. func (e *Episode) RemoveFromWatchlist() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", e.crunchy.Config.AccountID, e.SeriesID, e.crunchy.Locale) _, err := e.crunchy.request(endpoint, http.MethodDelete) diff --git a/error.go b/error.go new file mode 100644 index 0000000..b4f7329 --- /dev/null +++ b/error.go @@ -0,0 +1,17 @@ +package crunchyroll + +import ( + "fmt" + "net/http" +) + +type RequestError struct { + error + + Response *http.Response + Message string +} + +func (re *RequestError) String() string { + return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message) +} diff --git a/video.go b/video.go index 42efacd..3e7d7cf 100644 --- a/video.go +++ b/video.go @@ -195,6 +195,7 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) { } // AddToWatchlist adds the current episode to the watchlist. +// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before. func (s *Series) AddToWatchlist() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", s.crunchy.Config.AccountID, s.crunchy.Locale) body, _ := json.Marshal(map[string]string{"content_id": s.ID}) @@ -208,6 +209,7 @@ func (s *Series) AddToWatchlist() error { } // RemoveFromWatchlist removes the current episode from the watchlist. +// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before. func (s *Series) RemoveFromWatchlist() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", s.crunchy.Config.AccountID, s.ID, s.crunchy.Locale) _, err := s.crunchy.request(endpoint, http.MethodDelete) From f9792aa84781b893c9b589202caacd7325cdb347 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 00:54:30 +0200 Subject: [PATCH 03/32] Add extra file for common in different places used elements --- common.go | 39 +++++++++++++++++++++++++++++++++++++++ episode.go | 38 +------------------------------------- 2 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 common.go diff --git a/common.go b/common.go new file mode 100644 index 0000000..91e1312 --- /dev/null +++ b/common.go @@ -0,0 +1,39 @@ +package crunchyroll + +type Panel struct { + Title string `json:"title"` + PromoTitle string `json:"promo_title"` + Slug string `json:"slug"` + Playback string `json:"playback"` + PromoDescription string `json:"promo_description"` + Images struct { + Thumbnail [][]struct { + Height int `json:"height"` + Source string `json:"source"` + Type string `json:"type"` + Width int `json:"width"` + } `json:"thumbnail"` + PosterTall [][]struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"poster_tall"` + PosterWide [][]struct { + Width int `json:"width"` + Height int `json:"height"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"poster_wide"` + } `json:"images"` + ID string `json:"id"` + Description string `json:"description"` + ChannelID string `json:"channel_id"` + Type WatchlistEntryType `json:"type"` + ExternalID string `json:"external_id"` + SlugTitle string `json:"slug_title"` + // not null if Type is WATCHLISTENTRYEPISODE + EpisodeMetadata *Episode `json:"episode_metadata"` + // not null if Type is WATCHLISTENTRYSERIES + SeriesMetadata *Series `json:"series_metadata"` +} diff --git a/episode.go b/episode.go index d0e9b14..f97de31 100644 --- a/episode.go +++ b/episode.go @@ -102,43 +102,7 @@ const ( // WatchlistEntry contains information about an entry on the watchlist. type WatchlistEntry struct { - Panel struct { - Title string `json:"title"` - PromoTitle string `json:"promo_title"` - Slug string `json:"slug"` - Playback string `json:"playback"` - PromoDescription string `json:"promo_description"` - Images struct { - Thumbnail [][]struct { - Height int `json:"height"` - Source string `json:"source"` - Type string `json:"type"` - Width int `json:"width"` - } `json:"thumbnail"` - PosterTall [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"poster_tall"` - PosterWide [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"poster_wide"` - } `json:"images"` - ID string `json:"id"` - Description string `json:"description"` - ChannelID string `json:"channel_id"` - Type WatchlistEntryType `json:"type"` - ExternalID string `json:"external_id"` - SlugTitle string `json:"slug_title"` - // not null if Type is WATCHLISTENTRYEPISODE - EpisodeMetadata *Episode `json:"episode_metadata"` - // not null if Type is WATCHLISTENTRYSERIES - SeriesMetadata *Series `json:"series_metadata"` - } + Panel Panel `json:"panel"` New bool `json:"new"` NewContent bool `json:"new_content"` From 8ddb436fac31628aa016e29600fe0cef4f523f61 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 01:03:10 +0200 Subject: [PATCH 04/32] Move WatchlistEntry struct to own file --- episode.go | 12 ------------ watchlist.go | 13 +++++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 watchlist.go diff --git a/episode.go b/episode.go index f97de31..7f48e93 100644 --- a/episode.go +++ b/episode.go @@ -100,18 +100,6 @@ const ( WATCHLISTENTRYSERIES = "series" ) -// WatchlistEntry contains information about an entry on the watchlist. -type WatchlistEntry struct { - Panel Panel `json:"panel"` - - New bool `json:"new"` - NewContent bool `json:"new_content"` - IsFavorite bool `json:"is_favorite"` - NeverWatched bool `json:"never_watched"` - CompleteStatus bool `json:"complete_status"` - Playahead uint `json:"playahead"` -} - // 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/watchlist.go b/watchlist.go new file mode 100644 index 0000000..73fbb3d --- /dev/null +++ b/watchlist.go @@ -0,0 +1,13 @@ +package crunchyroll + +// WatchlistEntry contains information about an entry on the watchlist. +type WatchlistEntry struct { + Panel Panel `json:"panel"` + + New bool `json:"new"` + NewContent bool `json:"new_content"` + IsFavorite bool `json:"is_favorite"` + NeverWatched bool `json:"never_watched"` + CompleteStatus bool `json:"complete_status"` + Playahead uint `json:"playahead"` +} From aa088cb318afd60deed167356a2d8938cacccad9 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 01:42:57 +0200 Subject: [PATCH 05/32] Fix error printing caused panic --- error.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/error.go b/error.go index b4f7329..9bc2ae6 100644 --- a/error.go +++ b/error.go @@ -12,6 +12,6 @@ type RequestError struct { Message string } -func (re *RequestError) String() string { +func (re *RequestError) Error() string { return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message) } From 72484c78af9ec33060e7f5c5b8f64f8625683e69 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 01:56:39 +0200 Subject: [PATCH 06/32] Add crunchylists endpoint --- crunchylists.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ crunchyroll.go | 19 +++++++ 2 files changed, 167 insertions(+) create mode 100644 crunchylists.go diff --git a/crunchylists.go b/crunchylists.go new file mode 100644 index 0000000..1117dd7 --- /dev/null +++ b/crunchylists.go @@ -0,0 +1,148 @@ +package crunchyroll + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type CrunchyLists struct { + crunchy *Crunchyroll + + Items []*CrunchyListPreview `json:"items"` + TotalPublic int `json:"total_public"` + TotalPrivate int `json:"total_private"` + MaxPrivate int `json:"max_private"` +} + +func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"title": name}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + resp, err := cl.crunchy.requestFull(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + json.NewDecoder(resp.Body).Decode(&jsonBody) + + return CrunchyListFromID(cl.crunchy, jsonBody["list_id"].(string)) +} + +type CrunchyListPreview struct { + crunchy *Crunchyroll + + ListID string `json:"list_id"` + IsPublic bool `json:"is_public"` + Total int `json:"total"` + ModifiedAt time.Time `json:"modified_at"` + Title string `json:"title"` +} + +func (clp *CrunchyListPreview) CrunchyList() (*CrunchyList, error) { + return CrunchyListFromID(clp.crunchy, clp.ListID) +} + +func CrunchyListFromID(crunchy *Crunchyroll, id string) (*CrunchyList, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale) + resp, err := crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + crunchyList := &CrunchyList{ + crunchy: crunchy, + ID: id, + } + if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil { + return nil, err + } + for _, item := range crunchyList.Items { + item.crunchy = crunchy + } + return crunchyList, nil +} + +type CrunchyList struct { + crunchy *Crunchyroll + + ID string `json:"id"` + + Max int `json:"max"` + Total int `json:"total"` + Title string `json:"title"` + IsPublic bool `json:"is_public"` + ModifiedAt time.Time `json:"modified_at"` + Items []*CrunchyListItem `json:"items"` +} + +func (cl *CrunchyList) AddSeries(series *Series) error { + return cl.AddSeriesFromID(series.ID) +} + +func (cl *CrunchyList) AddSeriesFromID(id string) error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"content_id": id}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = cl.crunchy.requestFull(req) + return err +} + +func (cl *CrunchyList) RemoveSeries(series *Series) error { + return cl.RemoveSeriesFromID(series.ID) +} + +func (cl *CrunchyList) RemoveSeriesFromID(id string) error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale) + _, err := cl.crunchy.request(endpoint, http.MethodDelete) + return err +} + +func (cl *CrunchyList) Delete() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) + _, err := cl.crunchy.request(endpoint, http.MethodDelete) + return err +} + +func (cl *CrunchyList) Rename(name string) error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"title": name}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = cl.crunchy.requestFull(req) + if err == nil { + cl.Title = name + } + return err +} + +type CrunchyListItem struct { + crunchy *Crunchyroll + + ListID string `json:"list_id"` + ID string `json:"id"` + ModifiedAt time.Time `json:"modified_at"` + Panel Panel `json:"panel"` +} + +func (cli *CrunchyListItem) Remove() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID) + _, err := cli.crunchy.request(endpoint, http.MethodDelete) + return err +} diff --git a/crunchyroll.go b/crunchyroll.go index 4c29ab4..b9593f3 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -893,6 +893,25 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl 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) From 9f6a225caf58dc9b8f35133cc2c9fb4a1af71c94 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 02:00:58 +0200 Subject: [PATCH 07/32] Add better error output --- crunchyroll.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crunchyroll.go b/crunchyroll.go index b9593f3..3d07252 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -328,6 +328,11 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) { return nil, &RequestError{Response: resp, Message: msg} } } + } else if _, ok := errMap["code"]; ok { + if errContext, ok := errMap["context"]; ok { + errField := errContext.([]any)[0].(map[string]any) + return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errField["code"].(string), errField["field"].(string))} + } } } From c86595d2c6f3a6d07d7798814b074beba13c47c2 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 02:28:03 +0200 Subject: [PATCH 08/32] Add image to common structs --- common.go | 28 ++++++++++------------------ episode.go | 7 +------ movie_listing.go | 7 +------ video.go | 14 ++------------ 4 files changed, 14 insertions(+), 42 deletions(-) diff --git a/common.go b/common.go index 91e1312..b76a8fe 100644 --- a/common.go +++ b/common.go @@ -1,5 +1,12 @@ package crunchyroll +type Image struct { + Height int `json:"height"` + Source string `json:"source"` + Type string `json:"type"` + Width int `json:"width"` +} + type Panel struct { Title string `json:"title"` PromoTitle string `json:"promo_title"` @@ -7,24 +14,9 @@ type Panel struct { Playback string `json:"playback"` PromoDescription string `json:"promo_description"` Images struct { - Thumbnail [][]struct { - Height int `json:"height"` - Source string `json:"source"` - Type string `json:"type"` - Width int `json:"width"` - } `json:"thumbnail"` - PosterTall [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"poster_tall"` - PosterWide [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"poster_wide"` + Thumbnail [][]Image `json:"thumbnail"` + PosterTall [][]Image `json:"poster_tall"` + PosterWide [][]Image `json:"poster_wide"` } `json:"images"` ID string `json:"id"` Description string `json:"description"` diff --git a/episode.go b/episode.go index 7f48e93..7d9caac 100644 --- a/episode.go +++ b/episode.go @@ -61,12 +61,7 @@ type Episode struct { Slug string `json:"slug"` Images struct { - Thumbnail [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"thumbnail"` + Thumbnail [][]Image `json:"thumbnail"` } `json:"images"` DurationMS int `json:"duration_ms"` diff --git a/movie_listing.go b/movie_listing.go index 110f67a..81cb48d 100644 --- a/movie_listing.go +++ b/movie_listing.go @@ -19,12 +19,7 @@ type MovieListing struct { Description string `json:"description"` Images struct { - Thumbnail [][]struct { - Width int `json:"width"` - Height int `json:"height"` - Type string `json:"type"` - Source string `json:"source"` - } `json:"thumbnail"` + Thumbnail [][]Image `json:"thumbnail"` } `json:"images"` DurationMS int `json:"duration_ms"` diff --git a/video.go b/video.go index 3e7d7cf..3a67f5c 100644 --- a/video.go +++ b/video.go @@ -17,18 +17,8 @@ type video struct { SlugTitle string `json:"slug_title"` Images struct { - PosterTall [][]struct { - Height int `json:"height"` - Source string `json:"source"` - Type string `json:"type"` - Width int `json:"width"` - } `json:"poster_tall"` - PosterWide [][]struct { - Height int `json:"height"` - Source string `json:"source"` - Type string `json:"type"` - Width int `json:"width"` - } `json:"poster_wide"` + PosterTall [][]Image `json:"poster_tall"` + PosterWide [][]Image `json:"poster_wide"` } `json:"images"` } From 475dc34f7af0ab57de8def3b0489a2030f9983f9 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 13:31:29 +0200 Subject: [PATCH 09/32] Fix error handling caused panic --- crunchyroll.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crunchyroll.go b/crunchyroll.go index 3d07252..0544c70 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -331,7 +331,11 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) { } else if _, ok := errMap["code"]; ok { if errContext, ok := errMap["context"]; ok { errField := errContext.([]any)[0].(map[string]any) - return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errField["code"].(string), errField["field"].(string))} + var code string + if code, ok = errField["message"].(string); !ok { + code = errField["code"].(string) + } + return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", code, errField["field"].(string))} } } } From 0c93893627e4f0906f9de4c9158514bf2fc06016 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 13:38:41 +0200 Subject: [PATCH 10/32] Fix error handling caused panic (again) --- crunchyroll.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crunchyroll.go b/crunchyroll.go index 0544c70..8b4fad8 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -329,13 +329,15 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) { } } } else if _, ok := errMap["code"]; ok { - if errContext, ok := errMap["context"]; ok { - errField := errContext.([]any)[0].(map[string]any) + if errContext, ok := errMap["context"].([]any); ok && len(errContext) > 0 { + errField := errContext[0].(map[string]any) var code string if code, ok = errField["message"].(string); !ok { code = errField["code"].(string) } return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", code, errField["field"].(string))} + } else if errMessage, ok := errMap["message"].(string); ok { + return nil, &RequestError{Response: resp, Message: errMessage} } } } From cee34105327856a37b5619c67615b1b2cb6a8445 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sun, 19 Jun 2022 14:43:46 +0200 Subject: [PATCH 11/32] Add comment endpoint --- comment.go | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++ episode.go | 80 ++++++++++++++++ utils.go | 12 +++ 3 files changed, 354 insertions(+) create mode 100644 comment.go diff --git a/comment.go b/comment.go new file mode 100644 index 0000000..4dba10d --- /dev/null +++ b/comment.go @@ -0,0 +1,262 @@ +package crunchyroll + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type Comment struct { + crunchy *Crunchyroll + + EpisodeID string `json:"episode_id"` + + CommentID string `json:"comment_id"` + DomainID string `json:"domain_id"` + + GuestbookKey string `json:"guestbook_key"` + + User struct { + UserKey string `json:"user_key"` + UserAttributes struct { + Username string `json:"username"` + Avatar struct { + Locked []Image `json:"locked"` + Unlocked []Image `json:"unlocked"` + } `json:"avatar"` + } `json:"user_attributes"` + UserFlags []any `json:"user_flags"` + } `json:"user"` + + Message string `json:"message"` + ParentCommentID int `json:"parent_comment_id"` + + Locale LOCALE `json:"locale"` + + UserVotes []string `json:"user_votes"` + Flags []string `json:"flags"` + Votes struct { + Inappropriate int `json:"inappropriate"` + Like int `json:"like"` + Spoiler int `json:"spoiler"` + } `json:"votes"` + + DeleteReason any `json:"delete_reason"` + + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + + IsOwner bool `json:"is_owner"` + RepliesCount int `json:"replies_count"` +} + +// Delete deleted the current comment. Works only if the user has written the comment. +func (c *Comment) Delete() error { + if !c.IsOwner { + return fmt.Errorf("cannot delete, user is not the comment author") + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) + resp, err := c.crunchy.request(endpoint, http.MethodDelete) + if err != nil { + return err + } + defer resp.Body.Close() + + // the api returns a new comment object when modifying it. + // hopefully this does not change + json.NewDecoder(resp.Body).Decode(c) + + return nil +} + +// MarkAsSpoiler marks the current comment as spoiler. Works only if the user has written the comment, +// and it isn't already marked as spoiler. +func (c *Comment) MarkAsSpoiler() error { + if !c.IsOwner { + return fmt.Errorf("cannot mark as spoiler, user is not the comment author") + } else if c.markedAs("spoiler") { + return fmt.Errorf("comment is already marked as spoiler") + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) + body, _ := json.Marshal(map[string][]string{"add": {"spoiler"}}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.crunchy.requestFull(req) + if err != nil { + return err + } + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(c) + + return nil +} + +// UnmarkAsSpoiler unmarks the current comment as spoiler. Works only if the user has written the comment, +// and it is already marked as spoiler. +func (c *Comment) UnmarkAsSpoiler() error { + if !c.IsOwner { + return fmt.Errorf("cannot mark as spoiler, user is not the comment author") + } else if !c.markedAs("spoiler") { + return fmt.Errorf("comment is not marked as spoiler") + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) + body, _ := json.Marshal(map[string][]string{"remove": {"spoiler"}}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.crunchy.requestFull(req) + if err != nil { + return err + } + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(c) + + return nil +} + +// Like likes the comment. Works only if the user hasn't already liked it. +func (c *Comment) Like() error { + if err := c.vote("like", "liked"); err != nil { + return err + } + c.Votes.Like += 1 + + return nil +} + +// RemoveLike removes the like from the comment. Works only if the user has liked it. +func (c *Comment) RemoveLike() error { + if err := c.unVote("like", "liked"); err != nil { + return err + } + c.Votes.Like -= 1 + + return nil +} + +// Reply replies to the current comment. +func (c *Comment) Reply(message string, spoiler bool) (*Comment, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", c.EpisodeID, c.crunchy.Locale) + var flags []string + if spoiler { + flags = append(flags, "spoiler") + } + body, _ := json.Marshal(map[string]any{"locale": string(c.crunchy.Locale), "message": message, "flags": flags, "parent_id": c.CommentID}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.crunchy.requestFull(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + reply := &Comment{} + if err = json.NewDecoder(resp.Body).Decode(reply); err != nil { + return nil, err + } + + return reply, nil +} + +// Replies shows all replies to the current comment. +func (c *Comment) Replies(page uint, size uint) ([]*Comment, error) { + if c.RepliesCount == 0 { + return []*Comment{}, nil + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/replies?page_size=%d&page=%d&locale=%s", c.EpisodeID, c.CommentID, size, page, c.Locale) + resp, err := c.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]any + json.NewDecoder(resp.Body).Decode(&jsonBody) + + var comments []*Comment + if err = decodeMapToStruct(jsonBody["items"].([]any), &comments); err != nil { + return nil, err + } + return comments, nil +} + +// Report reports the comment. Only works if the comment hasn't been reported yet. +func (c *Comment) Report() error { + return c.vote("inappropriate", "reported") +} + +// UnreportComment removes the report request from the comment. Only works if the user +// has reported the comment. +func (c *Comment) UnreportComment() error { + return c.unVote("inappropriate", "reported") +} + +// FlagAsSpoiler sends a request to the user (and / or crunchyroll?) to mark the comment +// as spoiler. Only works if the comment hasn't been flagged as spoiler yet. +func (c *Comment) FlagAsSpoiler() error { + return c.vote("spoiler", "spoiler") +} + +// UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the +// comment as spoiler. Only works if the user has flagged the comment as spoiler. +func (c *Comment) UnflagAsSpoiler() error { + return c.unVote("spoiler", "spoiler") +} + +func (c *Comment) markedAs(voteType string) bool { + for _, userVote := range c.UserVotes { + if userVote == voteType { + return true + } + } + return false +} + +func (c *Comment) vote(voteType, readableName string) error { + if c.markedAs(voteType) { + return fmt.Errorf("comment is already marked as %s", readableName) + } + + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) + body, _ := json.Marshal(map[string]string{"vote_type": voteType}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = c.crunchy.requestFull(req) + if err != nil { + return err + } + c.UserVotes = append(c.UserVotes, voteType) + + return nil +} + +func (c *Comment) unVote(voteType, readableName string) error { + for i, userVote := range c.UserVotes { + if userVote == voteType { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?vote_type=%s&locale=%s", c.EpisodeID, c.CommentID, voteType, c.crunchy.Locale) + _, err := c.crunchy.request(endpoint, http.MethodDelete) + if err != nil { + return err + } + c.UserVotes = append(c.UserVotes[:i], c.UserVotes[i+1:]...) + return nil + } + } + + return fmt.Errorf("comment is not marked as %s", readableName) +} diff --git a/episode.go b/episode.go index 7d9caac..9e20510 100644 --- a/episode.go +++ b/episode.go @@ -167,6 +167,86 @@ func (e *Episode) AudioLocale() (LOCALE, error) { return streams[0].AudioLocale, nil } +// Comment creates a new comment under the episode. +func (e *Episode) Comment(message string, spoiler bool) (*Comment, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", e.ID, e.crunchy.Locale) + var flags []string + if spoiler { + flags = append(flags, "spoiler") + } + body, _ := json.Marshal(map[string]any{"locale": string(e.crunchy.Locale), "flags": flags, "message": message}) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + resp, err := e.crunchy.requestFull(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + c := &Comment{ + crunchy: e.crunchy, + EpisodeID: e.ID, + } + if err = json.NewDecoder(resp.Body).Decode(c); err != nil { + return nil, err + } + + return c, nil +} + +// CommentsOrderType represents a sort type to sort Episode.Comments after. +type CommentsOrderType string + +const ( + CommentsOrderAsc CommentsOrderType = "asc" + CommentsOrderDesc = "desc" +) + +type CommentsSortType string + +const ( + CommentsSortPopular CommentsSortType = "popular" + CommentsSortDate = "date" +) + +type CommentsOptions struct { + // Order specified the order how the comments should be returned. + Order CommentsOrderType `json:"order"` + + // Sort specified after which key the comments should be sorted. + Sort CommentsSortType `json:"sort"` +} + +// Comments returns comments under the given episode. +func (e *Episode) Comments(options CommentsOptions, page uint, size uint) (c []*Comment, err error) { + options, err = structDefaults(CommentsOptions{Order: CommentsOrderDesc, Sort: CommentsSortPopular}, options) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?page=%d&page_size=%d&order=%s&sort=%s&locale=%s", e.ID, page, size, options.Order, options.Sort, e.crunchy.Locale) + resp, err := e.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]any + json.NewDecoder(resp.Body).Decode(&jsonBody) + + if err = decodeMapToStruct(jsonBody["items"].([]any), &c); err != nil { + return nil, err + } + for _, comment := range c { + comment.crunchy = e.crunchy + comment.EpisodeID = e.ID + } + + return +} + // GetFormat returns the format which matches the given resolution and subtitle locale. func (e *Episode) GetFormat(resolution string, subtitle LOCALE, hardsub bool) (*Format, error) { streams, err := e.Streams() diff --git a/utils.go b/utils.go index d32d39b..a665040 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,7 @@ package crunchyroll import ( + "bytes" "encoding/json" "fmt" "net/url" @@ -70,3 +71,14 @@ func encodeStructToQueryValues(s interface{}) (string, error) { return values.Encode(), nil } + +func structDefaults[T any](defaultStruct T, customStruct T) (T, error) { + rawDefaultStruct, err := json.Marshal(defaultStruct) + if err != nil { + return *new(T), err + } + if err = json.NewDecoder(bytes.NewBuffer(rawDefaultStruct)).Decode(&customStruct); err != nil { + return *new(T), err + } + return customStruct, nil +} From 715ade831ca0387e5d45c447201e86bab5389552 Mon Sep 17 00:00:00 2001 From: bytedream Date: Mon, 20 Jun 2022 10:22:17 +0200 Subject: [PATCH 12/32] Add more account and profile endpoints --- account.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++- crunchyroll.go | 4 +- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/account.go b/account.go index d22eba5..87a6ef8 100644 --- a/account.go +++ b/account.go @@ -1,9 +1,16 @@ package crunchyroll -import "time" +import ( + "bytes" + "encoding/json" + "net/http" + "time" +) // Account contains information about a crunchyroll account. type Account struct { + crunchy *Crunchyroll + AccountID string `json:"account_id"` ExternalID string `json:"external_id"` EmailVerified bool `json:"email_verified"` @@ -25,5 +32,110 @@ type Account struct { PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"` PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"` QaUser bool `json:"qa_user"` - Username string `json:"username"` + + Username string `json:"username"` + Wallpaper *Wallpaper `json:"wallpaper"` +} + +// UpdateEmailLanguage sets in which language emails should be received. +func (a *Account) UpdateEmailLanguage(language LOCALE) error { + err := a.updatePreferences("preferred_communication_language", string(language)) + if err == nil { + a.PreferredCommunicationLanguage = language + } + return err +} + +// UpdateVideoSubtitleLanguage sets in which language default subtitles should be shown +func (a *Account) UpdateVideoSubtitleLanguage(language LOCALE) error { + err := a.updatePreferences("preferred_content_subtitle_language", string(language)) + if err == nil { + a.PreferredContentSubtitleLanguage = language + } + return err +} + +// UpdateMatureVideoContent sets if mature video content / 18+ content should be shown +func (a *Account) UpdateMatureVideoContent(enabled bool) error { + if enabled { + return a.updatePreferences("maturity_rating", "M3") + } else { + return a.updatePreferences("maturity_rating", "M2") + } +} + +// UpdateMatureMangaContent sets if mature manga content / 18+ content should be shown +func (a *Account) UpdateMatureMangaContent(enabled bool) error { + if enabled { + return a.updatePreferences("mature_content_flag_manga", "1") + } else { + return a.updatePreferences("mature_content_flag_manga", "0") + } +} + +func (a *Account) updatePreferences(name, value string) error { + endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile" + body, _ := json.Marshal(map[string]string{name: value}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = a.crunchy.requestFull(req) + return err +} + +// ChangePassword changes the password for the current account. +func (a *Account) ChangePassword(currentPassword, newPassword string) error { + endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials" + body, _ := json.Marshal(map[string]string{"accountId": a.AccountID, "current_password": currentPassword, "new_password": newPassword}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = a.crunchy.requestFull(req) + return err +} + +// ChangeEmail changes the email address for the current account. +func (a *Account) ChangeEmail(currentPassword, newEmail string) error { + endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials" + body, _ := json.Marshal(map[string]string{"current_password": currentPassword, "email": newEmail}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = a.crunchy.requestFull(req) + return err +} + +// AvailableWallpapers returns all available wallpapers which can be set as profile wallpaper. +func (a *Account) AvailableWallpapers() (w []*Wallpaper, err error) { + endpoint := "https://beta.crunchyroll.com/assets/v1/wallpaper" + resp, err := a.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]any + json.NewDecoder(resp.Body).Decode(&jsonBody) + + err = decodeMapToStruct(jsonBody["items"].([]any), &w) + return +} + +// ChangeWallpaper changes the profile wallpaper of the current user. Use AvailableWallpapers +// to get all available ones. +func (a *Account) ChangeWallpaper(wallpaper *Wallpaper) error { + endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile" + body, _ := json.Marshal(map[string]string{"wallpaper": string(*wallpaper)}) + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + _, err = a.crunchy.requestFull(req) + return err } diff --git a/crunchyroll.go b/crunchyroll.go index 8b4fad8..704003d 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -931,7 +931,9 @@ func (c *Crunchyroll) Account() (*Account, error) { } defer resp.Body.Close() - account := &Account{} + 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) From 256c97c2b748ed8e871a713b7d4451c9f56b55d2 Mon Sep 17 00:00:00 2001 From: bytedream Date: Mon, 20 Jun 2022 10:24:01 +0200 Subject: [PATCH 13/32] Fix Wallpaper type not found --- wallpaper.go | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 wallpaper.go diff --git a/wallpaper.go b/wallpaper.go new file mode 100644 index 0000000..f64cedf --- /dev/null +++ b/wallpaper.go @@ -0,0 +1,4 @@ +package crunchyroll + +// Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper. +type Wallpaper string From d1859b4c25c3f7f627b50f03f0f5202da681144a Mon Sep 17 00:00:00 2001 From: bytedream Date: Tue, 21 Jun 2022 17:43:12 +0200 Subject: [PATCH 14/32] Change const names to make them more readable --- crunchyroll.go | 26 +++++++++++++------------- episode.go | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crunchyroll.go b/crunchyroll.go index 704003d..5de3ab2 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -34,32 +34,32 @@ const ( type MediaType string const ( - SERIES MediaType = "series" - MOVIELISTING = "movie_listing" + MediaTypeSeries MediaType = "series" + 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" + 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 + WatchlistLanguageSubbed WatchlistLanguageType = iota + 1 + WatchlistLanguageDubbed ) type WatchlistContentType string const ( - WATCHLISTCONTENTSERIES WatchlistContentType = "series" - WATCHLISTCONTENTMOVIES = "movie_listing" + WatchlistContentSeries WatchlistContentType = "series" + WatchlistContentMovies = "movie_listing" ) type Crunchyroll struct { @@ -869,9 +869,9 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl values.Set("only_favorites", "true") } switch options.LanguageType { - case WATCHLISTLANGUAGESUBBED: + case WatchlistLanguageSubbed: values.Set("is_subbed", "true") - case WATCHLISTLANGUAGEDUBBED: + case WatchlistLanguageDubbed: values.Set("is_dubbed", "true") } values.Set("n", strconv.Itoa(int(limit))) @@ -894,9 +894,9 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl for _, entry := range watchlistEntries { switch entry.Panel.Type { - case WATCHLISTENTRYEPISODE: + case WatchlistEntryEpisode: entry.Panel.EpisodeMetadata.crunchy = c - case WATCHLISTENTRYSERIES: + case WatchlistEntrySeries: entry.Panel.SeriesMetadata.crunchy = c } } diff --git a/episode.go b/episode.go index 9e20510..b02280d 100644 --- a/episode.go +++ b/episode.go @@ -91,8 +91,8 @@ type HistoryEpisode struct { type WatchlistEntryType string const ( - WATCHLISTENTRYEPISODE = "episode" - WATCHLISTENTRYSERIES = "series" + WatchlistEntryEpisode = "episode" + WatchlistEntrySeries = "series" ) // EpisodeFromID returns an episode by its api id. From ec872d8c86dbfca2757f8ccaa0fa754e21c13703 Mon Sep 17 00:00:00 2001 From: bytedream Date: Tue, 21 Jun 2022 21:15:49 +0200 Subject: [PATCH 15/32] 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"` From f71846628daf690ea7dd0a9ab32760658fd155e9 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Tue, 21 Jun 2022 22:02:18 +0200 Subject: [PATCH 16/32] Refactor crunchyroll.go --- crunchyroll.go | 125 +++++++++++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/crunchyroll.go b/crunchyroll.go index a83415c..214cbe4 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -36,39 +36,6 @@ const ( MediaTypeMovie = "movie_listing" ) -type Crunchyroll struct { - // Client is the http.Client to perform all requests over. - Client *http.Client - // Context can be used to stop requests with Client and is context.Background by default. - Context context.Context - // Locale specifies in which language all results should be returned / requested. - Locale LOCALE - // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in - // this struct in v2 and below). - EtpRt string - - // Config stores parameters which are needed by some api calls. - Config struct { - TokenType string - AccessToken string - - Bucket string - - CountryCode string - Premium bool - Channel string - Policy string - Signature string - KeyPairID string - AccountID string - ExternalID string - MaturityRating string - } - - // If cache is true, internal caching is enabled. - cache bool -} - type loginResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -250,6 +217,69 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt return crunchy, nil } +type Crunchyroll struct { + // Client is the http.Client to perform all requests over. + Client *http.Client + // Context can be used to stop requests with Client and is context.Background by default. + Context context.Context + // Locale specifies in which language all results should be returned / requested. + Locale LOCALE + // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in + // this struct in v2 and below). + EtpRt string + + // Config stores parameters which are needed by some api calls. + Config struct { + TokenType string + AccessToken string + + Bucket string + + CountryCode string + Premium bool + Channel string + Policy string + Signature string + KeyPairID string + AccountID string + ExternalID string + MaturityRating string + } + + // If cache is true, internal caching is enabled. + cache bool +} + +// IsCaching returns if data gets cached or not. +// See SetCaching for more information. +func (c *Crunchyroll) IsCaching() bool { + return c.cache +} + +// SetCaching enables or disables internal caching of requests made. +// Caching is enabled by default. +// If it is disabled the already cached data still gets called. +// The best way to prevent this is to create a complete new Crunchyroll struct. +func (c *Crunchyroll) SetCaching(caching bool) { + c.cache = caching +} + +// request is a base function which handles simple api requests. +func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) { + req, err := http.NewRequest(method, endpoint, nil) + if err != nil { + return nil, err + } + return c.requestFull(req) +} + +// requestFull is a base function which handles full user controlled api requests. +func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) + + return request(req, c.Client) +} + func request(req *http.Request, client *http.Client) (*http.Response, error) { resp, err := client.Do(req) if err == nil { @@ -298,32 +328,3 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) { } return resp, err } - -// request is a base function which handles api requests. -func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) { - req, err := http.NewRequest(method, endpoint, nil) - if err != nil { - return nil, err - } - return c.requestFull(req) -} - -func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) { - req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) - - return request(req, c.Client) -} - -// IsCaching returns if data gets cached or not. -// See SetCaching for more information. -func (c *Crunchyroll) IsCaching() bool { - return c.cache -} - -// SetCaching enables or disables internal caching of requests made. -// Caching is enabled by default. -// If it is disabled the already cached data still gets called. -// The best way to prevent this is to create a complete new Crunchyroll struct. -func (c *Crunchyroll) SetCaching(caching bool) { - c.cache = caching -} From 2067c50937497fadf540107e5502ee005e9433b8 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Tue, 21 Jun 2022 22:04:22 +0200 Subject: [PATCH 17/32] Add logout endpoint --- crunchyroll.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crunchyroll.go b/crunchyroll.go index 214cbe4..9c786c6 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -250,6 +250,16 @@ type Crunchyroll struct { cache bool } +// Logout logs the user out which invalidates the current session. +// You have to call a login method again and create a new Crunchyroll instance +// if you want to perform any further actions since this instance is not usable +// anymore after calling this. +func (c *Crunchyroll) Logout() error { + endpoint := "https://crunchyroll.com/logout" + _, err := c.request(endpoint, http.MethodGet) + return err +} + // IsCaching returns if data gets cached or not. // See SetCaching for more information. func (c *Crunchyroll) IsCaching() bool { From bfad0caa9a95fc93b663f6897b25acbf0e596f38 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 13:52:10 +0200 Subject: [PATCH 18/32] Update email language and video sub language setting function names --- account.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/account.go b/account.go index f2d6a70..cb45a77 100644 --- a/account.go +++ b/account.go @@ -67,8 +67,8 @@ type Account struct { Wallpaper *Wallpaper `json:"wallpaper"` } -// UpdateEmailLanguage sets in which language emails should be received. -func (a *Account) UpdateEmailLanguage(language LOCALE) error { +// UpdatePreferredEmailLanguage sets in which language emails should be received. +func (a *Account) UpdatePreferredEmailLanguage(language LOCALE) error { err := a.updatePreferences("preferred_communication_language", string(language)) if err == nil { a.PreferredCommunicationLanguage = language @@ -76,8 +76,8 @@ func (a *Account) UpdateEmailLanguage(language LOCALE) error { return err } -// UpdateVideoSubtitleLanguage sets in which language default subtitles should be shown -func (a *Account) UpdateVideoSubtitleLanguage(language LOCALE) error { +// UpdatePreferredVideoSubtitleLanguage sets in which language default subtitles should be shown +func (a *Account) UpdatePreferredVideoSubtitleLanguage(language LOCALE) error { err := a.updatePreferences("preferred_content_subtitle_language", string(language)) if err == nil { a.PreferredContentSubtitleLanguage = language From a283ba724785d537c56ce3967dd0f1b2fe489290 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 14:27:15 +0200 Subject: [PATCH 19/32] Add functions to get wallpaper urls --- wallpaper.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/wallpaper.go b/wallpaper.go index f64cedf..da7a032 100644 --- a/wallpaper.go +++ b/wallpaper.go @@ -1,4 +1,14 @@ package crunchyroll +import "fmt" + // Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper. type Wallpaper string + +func (w *Wallpaper) TinyUrl() string { + return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/360x115/%s", *w) +} + +func (w *Wallpaper) BigUrl() string { + return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/1920x400/%s", *w) +} From 28070bd32dbd8d5f1c126836ce130b5d1a79323a Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 16:44:34 +0200 Subject: [PATCH 20/32] Add function to check if a comment is marked as spoiler --- comment.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/comment.go b/comment.go index 4dba10d..47005cf 100644 --- a/comment.go +++ b/comment.go @@ -71,6 +71,16 @@ func (c *Comment) Delete() error { return nil } +// IsSpoiler returns if the comment is marked as spoiler or not. +func (c *Comment) IsSpoiler() bool { + for _, flag := range c.Flags { + if flag == "spoiler" { + return true + } + } + return false +} + // MarkAsSpoiler marks the current comment as spoiler. Works only if the user has written the comment, // and it isn't already marked as spoiler. func (c *Comment) MarkAsSpoiler() error { From ead1db2be8f7bc46c9ce60e7be24acb946a626e0 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 16:50:48 +0200 Subject: [PATCH 21/32] Add function to check if a comment is liked by the logged-in user --- comment.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/comment.go b/comment.go index 47005cf..762b57b 100644 --- a/comment.go +++ b/comment.go @@ -143,6 +143,16 @@ func (c *Comment) Like() error { return nil } +// Liked returns if the user has liked the comment. +func (c *Comment) Liked() bool { + for _, flag := range c.Flags { + if flag == "liked" { + return true + } + } + return false +} + // RemoveLike removes the like from the comment. Works only if the user has liked it. func (c *Comment) RemoveLike() error { if err := c.unVote("like", "liked"); err != nil { From 9919a48e9ae52ea6050537f5a4adca9723b8e4b6 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 16:57:49 +0200 Subject: [PATCH 22/32] Change 'UnreportComment' to 'RemoveReport' --- comment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/comment.go b/comment.go index 762b57b..811433f 100644 --- a/comment.go +++ b/comment.go @@ -217,9 +217,9 @@ func (c *Comment) Report() error { return c.vote("inappropriate", "reported") } -// UnreportComment removes the report request from the comment. Only works if the user +// RemoveReport removes the report request from the comment. Only works if the user // has reported the comment. -func (c *Comment) UnreportComment() error { +func (c *Comment) RemoveReport() error { return c.unVote("inappropriate", "reported") } From f03287856b3f509f9ac1fbe68fe9580deef13a8b Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 16:59:04 +0200 Subject: [PATCH 23/32] Add function to check if comment is reported --- comment.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/comment.go b/comment.go index 811433f..98c400c 100644 --- a/comment.go +++ b/comment.go @@ -217,6 +217,10 @@ func (c *Comment) Report() error { return c.vote("inappropriate", "reported") } +func (c *Comment) IsReported() bool { + return c.markedAs("reported") +} + // RemoveReport removes the report request from the comment. Only works if the user // has reported the comment. func (c *Comment) RemoveReport() error { From 0521895f11e9cbafe49585f1fe65f637f888036c Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 16:59:54 +0200 Subject: [PATCH 24/32] Add function to check if comment is flagged as spoiler --- comment.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/comment.go b/comment.go index 98c400c..dc68201 100644 --- a/comment.go +++ b/comment.go @@ -233,6 +233,10 @@ func (c *Comment) FlagAsSpoiler() error { return c.vote("spoiler", "spoiler") } +func (c *Comment) IsFlaggedAsSpoiler() bool { + return c.markedAs("spoiler") +} + // UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the // comment as spoiler. Only works if the user has flagged the comment as spoiler. func (c *Comment) UnflagAsSpoiler() error { From 4cfcc11e206cda21ffd3ff7c7413bac088a5cdc6 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 17:00:46 +0200 Subject: [PATCH 25/32] Simplified 'Liked' --- comment.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/comment.go b/comment.go index dc68201..333ec4b 100644 --- a/comment.go +++ b/comment.go @@ -145,12 +145,7 @@ func (c *Comment) Like() error { // Liked returns if the user has liked the comment. func (c *Comment) Liked() bool { - for _, flag := range c.Flags { - if flag == "liked" { - return true - } - } - return false + return c.markedAs("liked") } // RemoveLike removes the like from the comment. Works only if the user has liked it. From 14491ce6c954cdcd486ccc4876027ee003da5aee Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 17:01:08 +0200 Subject: [PATCH 26/32] Rename 'markedAs' to 'votedAs' --- comment.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/comment.go b/comment.go index 333ec4b..d58fade 100644 --- a/comment.go +++ b/comment.go @@ -86,7 +86,7 @@ func (c *Comment) IsSpoiler() bool { func (c *Comment) MarkAsSpoiler() error { if !c.IsOwner { return fmt.Errorf("cannot mark as spoiler, user is not the comment author") - } else if c.markedAs("spoiler") { + } else if c.votedAs("spoiler") { return fmt.Errorf("comment is already marked as spoiler") } endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) @@ -112,7 +112,7 @@ func (c *Comment) MarkAsSpoiler() error { func (c *Comment) UnmarkAsSpoiler() error { if !c.IsOwner { return fmt.Errorf("cannot mark as spoiler, user is not the comment author") - } else if !c.markedAs("spoiler") { + } else if !c.votedAs("spoiler") { return fmt.Errorf("comment is not marked as spoiler") } endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale) @@ -145,7 +145,7 @@ func (c *Comment) Like() error { // Liked returns if the user has liked the comment. func (c *Comment) Liked() bool { - return c.markedAs("liked") + return c.votedAs("liked") } // RemoveLike removes the like from the comment. Works only if the user has liked it. @@ -213,7 +213,7 @@ func (c *Comment) Report() error { } func (c *Comment) IsReported() bool { - return c.markedAs("reported") + return c.votedAs("reported") } // RemoveReport removes the report request from the comment. Only works if the user @@ -229,7 +229,7 @@ func (c *Comment) FlagAsSpoiler() error { } func (c *Comment) IsFlaggedAsSpoiler() bool { - return c.markedAs("spoiler") + return c.votedAs("spoiler") } // UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the @@ -238,7 +238,7 @@ func (c *Comment) UnflagAsSpoiler() error { return c.unVote("spoiler", "spoiler") } -func (c *Comment) markedAs(voteType string) bool { +func (c *Comment) votedAs(voteType string) bool { for _, userVote := range c.UserVotes { if userVote == voteType { return true @@ -248,7 +248,7 @@ func (c *Comment) markedAs(voteType string) bool { } func (c *Comment) vote(voteType, readableName string) error { - if c.markedAs(voteType) { + if c.votedAs(voteType) { return fmt.Errorf("comment is already marked as %s", readableName) } From e6172cdf90685e5f363c984f77301a34c2dc5076 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 17:16:47 +0200 Subject: [PATCH 27/32] Change watchlist option order type and content type --- watchlist.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/watchlist.go b/watchlist.go index b4db34d..6fa0bc0 100644 --- a/watchlist.go +++ b/watchlist.go @@ -16,18 +16,17 @@ const ( WatchlistLanguageDubbed ) -// WatchlistContentType represents a filter type to filter Crunchyroll.Watchlist entries if they're series or movies. -type WatchlistContentType string +type WatchlistOrderType string const ( - WatchlistContentSeries WatchlistContentType = "series" - WatchlistContentMovies = "movie_listing" + WatchlistOrderAsc = "asc" + WatchlistOrderDesc = "desc" ) // WatchlistOptions represents options for receiving the user watchlist. type WatchlistOptions struct { - // OrderAsc specified whether the results should be order ascending or descending. - OrderAsc bool + // Order specified whether the results should be order ascending or descending. + Order WatchlistOrderType // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned. OnlyFavorites bool @@ -38,17 +37,16 @@ type WatchlistOptions struct { // 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 + ContentType MediaType } // 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.Order == "" { + options.Order = WatchlistOrderDesc } + values.Set("order", string(options.Order)) if options.OnlyFavorites { values.Set("only_favorites", "true") } From 1a4abdc4d817f3a95ca09ee7e249a905ead3ac32 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 17:31:26 +0200 Subject: [PATCH 28/32] Made 'l' in crunchylist lowercase and made CrunchylistFromID priave --- crunchylists.go | 86 ++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/crunchylists.go b/crunchylists.go index a2a7a1f..b23ae00 100644 --- a/crunchylists.go +++ b/crunchylists.go @@ -8,7 +8,7 @@ import ( "time" ) -func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) { +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 { @@ -16,27 +16,27 @@ func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) { } defer resp.Body.Close() - crunchyLists := &CrunchyLists{ + crunchylists := &Crunchylists{ crunchy: c, } - json.NewDecoder(resp.Body).Decode(crunchyLists) - for _, item := range crunchyLists.Items { + json.NewDecoder(resp.Body).Decode(crunchylists) + for _, item := range crunchylists.Items { item.crunchy = c } - return crunchyLists, nil + return crunchylists, nil } -type CrunchyLists struct { +type Crunchylists struct { crunchy *Crunchyroll - Items []*CrunchyListPreview `json:"items"` + Items []*CrunchylistPreview `json:"items"` TotalPublic int `json:"total_public"` TotalPrivate int `json:"total_private"` MaxPrivate int `json:"max_private"` } -func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) { +func (cl *Crunchylists) Create(name string) (*Crunchylist, error) { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"title": name}) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) @@ -53,10 +53,10 @@ func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) { var jsonBody map[string]interface{} json.NewDecoder(resp.Body).Decode(&jsonBody) - return CrunchyListFromID(cl.crunchy, jsonBody["list_id"].(string)) + return crunchylistFromID(cl.crunchy, jsonBody["list_id"].(string)) } -type CrunchyListPreview struct { +type CrunchylistPreview struct { crunchy *Crunchyroll ListID string `json:"list_id"` @@ -66,32 +66,11 @@ type CrunchyListPreview struct { Title string `json:"title"` } -func (clp *CrunchyListPreview) CrunchyList() (*CrunchyList, error) { - return CrunchyListFromID(clp.crunchy, clp.ListID) +func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) { + return crunchylistFromID(clp.crunchy, clp.ListID) } -func CrunchyListFromID(crunchy *Crunchyroll, id string) (*CrunchyList, error) { - endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale) - resp, err := crunchy.request(endpoint, http.MethodGet) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - crunchyList := &CrunchyList{ - crunchy: crunchy, - ID: id, - } - if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil { - return nil, err - } - for _, item := range crunchyList.Items { - item.crunchy = crunchy - } - return crunchyList, nil -} - -type CrunchyList struct { +type Crunchylist struct { crunchy *Crunchyroll ID string `json:"id"` @@ -101,14 +80,14 @@ type CrunchyList struct { Title string `json:"title"` IsPublic bool `json:"is_public"` ModifiedAt time.Time `json:"modified_at"` - Items []*CrunchyListItem `json:"items"` + Items []*CrunchylistItem `json:"items"` } -func (cl *CrunchyList) AddSeries(series *Series) error { +func (cl *Crunchylist) AddSeries(series *Series) error { return cl.AddSeriesFromID(series.ID) } -func (cl *CrunchyList) AddSeriesFromID(id string) error { +func (cl *Crunchylist) AddSeriesFromID(id string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"content_id": id}) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) @@ -120,23 +99,23 @@ func (cl *CrunchyList) AddSeriesFromID(id string) error { return err } -func (cl *CrunchyList) RemoveSeries(series *Series) error { +func (cl *Crunchylist) RemoveSeries(series *Series) error { return cl.RemoveSeriesFromID(series.ID) } -func (cl *CrunchyList) RemoveSeriesFromID(id string) error { +func (cl *Crunchylist) RemoveSeriesFromID(id string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale) _, err := cl.crunchy.request(endpoint, http.MethodDelete) return err } -func (cl *CrunchyList) Delete() error { +func (cl *Crunchylist) Delete() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) _, err := cl.crunchy.request(endpoint, http.MethodDelete) return err } -func (cl *CrunchyList) Rename(name string) error { +func (cl *Crunchylist) Rename(name string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"title": name}) req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body)) @@ -151,7 +130,28 @@ func (cl *CrunchyList) Rename(name string) error { return err } -type CrunchyListItem struct { +func crunchylistFromID(crunchy *Crunchyroll, id string) (*Crunchylist, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale) + resp, err := crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + crunchyList := &Crunchylist{ + crunchy: crunchy, + ID: id, + } + if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil { + return nil, err + } + for _, item := range crunchyList.Items { + item.crunchy = crunchy + } + return crunchyList, nil +} + +type CrunchylistItem struct { crunchy *Crunchyroll ListID string `json:"list_id"` @@ -160,7 +160,7 @@ type CrunchyListItem struct { Panel Panel `json:"panel"` } -func (cli *CrunchyListItem) Remove() error { +func (cli *CrunchylistItem) Remove() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID) _, err := cli.crunchy.request(endpoint, http.MethodDelete) return err From fa2321e9e8517ab66a55a95e0d7bfb71c495ec36 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Thu, 23 Jun 2022 17:40:29 +0200 Subject: [PATCH 29/32] Rename 'Logout' to 'InvalidateSession' --- crunchyroll.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crunchyroll.go b/crunchyroll.go index 9c786c6..a8b3284 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -250,11 +250,11 @@ type Crunchyroll struct { cache bool } -// Logout logs the user out which invalidates the current session. +// InvalidateSession logs the user out which invalidates the current session. // You have to call a login method again and create a new Crunchyroll instance // if you want to perform any further actions since this instance is not usable // anymore after calling this. -func (c *Crunchyroll) Logout() error { +func (c *Crunchyroll) InvalidateSession() error { endpoint := "https://crunchyroll.com/logout" _, err := c.request(endpoint, http.MethodGet) return err From 2569ddd1c7ed1a6c3006fd87a5bc79bff2e11661 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Fri, 24 Jun 2022 11:34:02 +0200 Subject: [PATCH 30/32] Add docs to crunchylists --- crunchylists.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crunchylists.go b/crunchylists.go index b23ae00..a297da2 100644 --- a/crunchylists.go +++ b/crunchylists.go @@ -8,6 +8,7 @@ import ( "time" ) +// Crunchylists returns a struct to control crunchylists. 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) @@ -36,6 +37,7 @@ type Crunchylists struct { MaxPrivate int `json:"max_private"` } +// Create creates a new crunchylist with the given name. Duplicate names for lists are allowed. func (cl *Crunchylists) Create(name string) (*Crunchylist, error) { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"title": name}) @@ -66,6 +68,7 @@ type CrunchylistPreview struct { Title string `json:"title"` } +// Crunchylist returns the belonging Crunchylist struct. func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) { return crunchylistFromID(clp.crunchy, clp.ListID) } @@ -83,10 +86,12 @@ type Crunchylist struct { Items []*CrunchylistItem `json:"items"` } +// AddSeries adds a series. func (cl *Crunchylist) AddSeries(series *Series) error { return cl.AddSeriesFromID(series.ID) } +// AddSeriesFromID adds a series from its id func (cl *Crunchylist) AddSeriesFromID(id string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"content_id": id}) @@ -99,22 +104,26 @@ func (cl *Crunchylist) AddSeriesFromID(id string) error { return err } +// RemoveSeries removes a series func (cl *Crunchylist) RemoveSeries(series *Series) error { return cl.RemoveSeriesFromID(series.ID) } +// RemoveSeriesFromID removes a series by its id func (cl *Crunchylist) RemoveSeriesFromID(id string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale) _, err := cl.crunchy.request(endpoint, http.MethodDelete) return err } +// Delete deleted the current crunchylist. func (cl *Crunchylist) Delete() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) _, err := cl.crunchy.request(endpoint, http.MethodDelete) return err } +// Rename renames the current crunchylist. func (cl *Crunchylist) Rename(name string) error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale) body, _ := json.Marshal(map[string]string{"title": name}) @@ -160,6 +169,7 @@ type CrunchylistItem struct { Panel Panel `json:"panel"` } +// Remove removes the current item from its crunchylist. func (cli *CrunchylistItem) Remove() error { endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID) _, err := cli.crunchy.request(endpoint, http.MethodDelete) From 3dcfbc0fbb5c00caad58d3309145356e21ea019b Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Fri, 24 Jun 2022 11:51:12 +0200 Subject: [PATCH 31/32] Add docs to wallpaper --- wallpaper.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wallpaper.go b/wallpaper.go index da7a032..e99bafb 100644 --- a/wallpaper.go +++ b/wallpaper.go @@ -5,10 +5,12 @@ import "fmt" // Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper. type Wallpaper string +// TinyUrl returns the url to the wallpaper in low resolution. func (w *Wallpaper) TinyUrl() string { return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/360x115/%s", *w) } +// BigUrl returns the url to the wallpaper in high resolution. func (w *Wallpaper) BigUrl() string { return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/1920x400/%s", *w) } From 79c3ba2636a188697a6161d0d68b4ba8cdedc664 Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Fri, 24 Jun 2022 11:51:48 +0200 Subject: [PATCH 32/32] Refactor to use consts instead of string --- search.go | 8 ++++---- suggestions.go | 4 ++-- video.go | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/search.go b/search.go index d7f6e76..cbbf7e9 100644 --- a/search.go +++ b/search.go @@ -61,7 +61,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m for _, item := range jsonBody["items"].([]interface{}) { switch item.(map[string]interface{})["type"] { - case "series": + case MediaTypeSeries: series := &Series{ crunchy: c, } @@ -73,7 +73,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m } s = append(s, series) - case "movie_listing": + case MediaTypeMovie: movie := &Movie{ crunchy: c, } @@ -160,7 +160,7 @@ func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, item := item.(map[string]interface{}) if item["total"].(float64) > 0 { switch item["type"] { - case "series": + case MediaTypeSeries: for _, series := range item["items"].([]interface{}) { series2 := &Series{ crunchy: c, @@ -174,7 +174,7 @@ func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, s = append(s, series2) } - case "movie_listing": + case MediaTypeMovie: for _, movie := range item["items"].([]interface{}) { movie2 := &Movie{ crunchy: c, diff --git a/suggestions.go b/suggestions.go index d6bd770..35d7a9b 100644 --- a/suggestions.go +++ b/suggestions.go @@ -23,7 +23,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err for _, item := range jsonBody["items"].([]interface{}) { switch item.(map[string]interface{})["type"] { - case "series": + case MediaTypeSeries: series := &Series{ crunchy: c, } @@ -35,7 +35,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err } s = append(s, series) - case "movie_listing": + case MediaTypeMovie: movie := &Movie{ crunchy: c, } diff --git a/video.go b/video.go index 2d76499..04ea5e9 100644 --- a/video.go +++ b/video.go @@ -223,7 +223,7 @@ func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) { for _, item := range jsonBody["items"].([]interface{}) { switch item.(map[string]interface{})["type"] { - case "series": + case MediaTypeSeries: series := &Series{ crunchy: s.crunchy, } @@ -235,7 +235,7 @@ func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) { } ss = append(ss, series) - case "movie_listing": + case MediaTypeMovie: movie := &Movie{ crunchy: s.crunchy, }