diff --git a/account.go b/account.go index d22eba5..cb45a77 100644 --- a/account.go +++ b/account.go @@ -1,9 +1,46 @@ package crunchyroll -import "time" +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 + AccountID string `json:"account_id"` ExternalID string `json:"external_id"` EmailVerified bool `json:"email_verified"` @@ -25,5 +62,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"` +} + +// 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 + } + return err +} + +// 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 + } + 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/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/comment.go b/comment.go new file mode 100644 index 0000000..d58fade --- /dev/null +++ b/comment.go @@ -0,0 +1,285 @@ +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 +} + +// 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 { + if !c.IsOwner { + return fmt.Errorf("cannot mark as spoiler, user is not the comment author") + } 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) + 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.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) + 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 +} + +// Liked returns if the user has liked the comment. +func (c *Comment) Liked() bool { + return c.votedAs("liked") +} + +// 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") +} + +func (c *Comment) IsReported() bool { + return c.votedAs("reported") +} + +// RemoveReport removes the report request from the comment. Only works if the user +// has reported the comment. +func (c *Comment) RemoveReport() 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") +} + +func (c *Comment) IsFlaggedAsSpoiler() bool { + return c.votedAs("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) votedAs(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.votedAs(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/common.go b/common.go new file mode 100644 index 0000000..b76a8fe --- /dev/null +++ b/common.go @@ -0,0 +1,31 @@ +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"` + Slug string `json:"slug"` + Playback string `json:"playback"` + PromoDescription string `json:"promo_description"` + Images struct { + Thumbnail [][]Image `json:"thumbnail"` + PosterTall [][]Image `json:"poster_tall"` + PosterWide [][]Image `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/crunchylists.go b/crunchylists.go new file mode 100644 index 0000000..a297da2 --- /dev/null +++ b/crunchylists.go @@ -0,0 +1,177 @@ +package crunchyroll + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "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) + 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 + + Items []*CrunchylistPreview `json:"items"` + TotalPublic int `json:"total_public"` + TotalPrivate int `json:"total_private"` + 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}) + 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"` +} + +// Crunchylist returns the belonging Crunchylist struct. +func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) { + return crunchylistFromID(clp.crunchy, clp.ListID) +} + +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"` +} + +// 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}) + 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 +} + +// 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}) + 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 +} + +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"` + ID string `json:"id"` + ModifiedAt time.Time `json:"modified_at"` + 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) + return err +} diff --git a/crunchyroll.go b/crunchyroll.go index be5af03..c697034 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -4,13 +4,10 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" "net/url" - "regexp" - "strconv" "strings" ) @@ -35,75 +32,10 @@ const ( type MediaType string const ( - SERIES MediaType = "series" - MOVIELISTING = "movie_listing" + MediaTypeSeries MediaType = "series" + MediaTypeMovie = "movie_listing" ) -// SortType represents a sort type. -type SortType string - -const ( - POPULARITY SortType = "popularity" - NEWLYADDED = "newly_added" - ALPHABETICAL = "alphabetical" -) - -type Crunchyroll struct { - // Client is the http.Client to perform all requests over. - Client *http.Client - // Context can be used to stop requests with Client. - 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 - Policy string - Signature string - KeyPairID string - AccountID string - ExternalID string - MaturityRating string - } - - // If cache is true, internal caching is enabled. - cache bool -} - -// BrowseOptions represents options for browsing the crunchyroll catalog. -type BrowseOptions struct { - // Categories specifies the categories of the entries. - Categories []string `param:"categories"` - - // IsDubbed specifies whether the entries should be dubbed. - IsDubbed bool `param:"is_dubbed"` - - // IsSubbed specifies whether the entries should be subbed. - IsSubbed bool `param:"is_subbed"` - - // Simulcast specifies a particular simulcast season by id in which the entries have been aired. - Simulcast string `param:"season_tag"` - - // Sort specifies how the entries should be sorted. - Sort SortType `param:"sort_by"` - - // Start specifies the index from which the entries should be returned. - Start uint `param:"start"` - - // Type specifies the media type of the entries. - Type MediaType `param:"type"` -} - type loginResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` @@ -111,9 +43,6 @@ type loginResponse struct { Scope string `json:"scope"` Country string `json:"country"` AccountID string `json:"account_id"` - - Error bool `json:"error"` - Message string `json:"message"` } // LoginWithCredentials logs in via crunchyroll username or email and password. @@ -139,11 +68,6 @@ func LoginWithCredentials(user string, password string, locale LOCALE, client *h var loginResp loginResponse json.NewDecoder(resp.Body).Decode(&loginResp) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to auth with credentials: %s", resp.Status) - } else if loginResp.Error { - return nil, fmt.Errorf("an unexpected login error occoured: %s", loginResp.Message) - } var etpRt string for _, cookie := range resp.Cookies() { @@ -181,16 +105,9 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (* if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { return nil, fmt.Errorf("failed to parse start session with session id response: %w", err) } - if isError, ok := jsonBody["error"]; ok && isError.(bool) { return nil, fmt.Errorf("invalid session id (%s): %s", jsonBody["message"].(string), jsonBody["code"]) } - data := jsonBody["data"].(map[string]interface{}) - - user := data["user"] - if user == nil { - return nil, errors.New("invalid session id, user is not logged in") - } var etpRt string for _, cookie := range resp.Cookies() { @@ -258,9 +175,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt json.NewDecoder(resp.Body).Decode(&jsonBody) cms := jsonBody["cms"].(map[string]any) - crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/") crunchy.Config.Premium = strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") - // / is trimmed so that urls which require it must be in .../{bucket}/... like format. // this just looks cleaner crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/") @@ -289,53 +204,47 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt return crunchy, nil } -func request(req *http.Request, client *http.Client) (*http.Response, error) { - resp, err := client.Do(req) - if err == nil { - var buf bytes.Buffer - io.Copy(&buf, resp.Body) - defer resp.Body.Close() - defer func() { - resp.Body = io.NopCloser(&buf) - }() +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 - if buf.Len() != 0 { - var errMap map[string]any + // Config stores parameters which are needed by some api calls. + Config struct { + TokenType string + AccessToken string - if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil { - return nil, fmt.Errorf("invalid json response: %w", err) - } + Bucket string - 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, fmt.Errorf("error for endpoint %s (%d): %s", req.URL.String(), resp.StatusCode, 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) - } - } - } - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("error for endpoint %s: %s", req.URL.String(), resp.Status) - } + CountryCode string + Premium bool + Channel string + Policy string + Signature string + KeyPairID string + AccountID string + ExternalID string + MaturityRating string } - return resp, err + + // If cache is true, internal caching is enabled. + cache bool } -// 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 - } - req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) - - return request(req, c.Client) +// 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) InvalidateSession() error { + endpoint := "https://crunchyroll.com/logout" + _, err := c.request(endpoint, http.MethodGet) + return err } // IsCaching returns if data gets cached or not. @@ -352,493 +261,67 @@ 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) +// 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, nil, err + return nil, err } - defer resp.Body.Close() + return c.requestFull(req) +} - 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) - } +// 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)) - 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 - } + return request(req, c.Client) +} - s = append(s, series2) +func request(req *http.Request, client *http.Client) (*http.Response, error) { + resp, err := client.Do(req) + if err == nil { + var buf bytes.Buffer + io.Copy(&buf, resp.Body) + defer resp.Body.Close() + defer func() { + resp.Body = io.NopCloser(&buf) + }() + + if buf.Len() != 0 { + var errMap map[string]any + + if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil { + 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, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errorAsString, code)} + } + return nil, &RequestError{Response: resp, Message: errorAsString} + } else if errorAsBool, ok := val.(bool); ok && errorAsBool { + if msg, ok := errMap["message"].(string); ok { + return nil, &RequestError{Response: resp, Message: msg} + } } - case "movie_listing": - for _, movie := range item["items"].([]interface{}) { - movie2 := &Movie{ - crunchy: c, + } else if _, ok := errMap["code"]; ok { + 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) } - if err := decodeMapToStruct(movie, movie2); err != nil { - return nil, nil, err - } - - m = append(m, movie2) + 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} } } } - } - 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 -} - -// 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{} - - 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 + if resp.StatusCode >= 400 { + return nil, &RequestError{Response: resp, Message: resp.Status} + } + } + return resp, err } diff --git a/episode.go b/episode.go index c90a5c7..caf1e09 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,16 +56,12 @@ 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 { - 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"` @@ -87,6 +87,14 @@ type HistoryEpisode struct { FullyWatched bool `json:"fully_watched"` } +// WatchlistEntryType specifies which type a watchlist entry has. +type WatchlistEntryType string + +const ( + WatchlistEntryEpisode = "episode" + WatchlistEntrySeries = "series" +) + // 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/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s", @@ -120,6 +128,31 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) { return episode, nil } +// 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. +// 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. +// 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) + 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 @@ -134,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 +} + // Available returns if downloadable streams for this episodes are available. func (e *Episode) Available() bool { return e.crunchy.Config.Premium || !e.IsPremiumOnly diff --git a/error.go b/error.go new file mode 100644 index 0000000..9bc2ae6 --- /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) Error() string { + return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message) +} diff --git a/movie_listing.go b/movie_listing.go index ec49e99..f9bbc21 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/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..cbbf7e9 --- /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 MediaTypeSeries: + 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 MediaTypeMovie: + 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 MediaTypeSeries: + 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 MediaTypeMovie: + 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..35d7a9b --- /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 MediaTypeSeries: + 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 MediaTypeMovie: + 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/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 +} diff --git a/video.go b/video.go index f72a327..810244f 100644 --- a/video.go +++ b/video.go @@ -1,6 +1,7 @@ package crunchyroll import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -16,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"` } @@ -187,6 +178,71 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) { return series, nil } +// 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}) + 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. +// 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) + 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 MediaTypeSeries: + 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 MediaTypeMovie: + 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/wallpaper.go b/wallpaper.go new file mode 100644 index 0000000..e99bafb --- /dev/null +++ b/wallpaper.go @@ -0,0 +1,16 @@ +package crunchyroll + +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) +} 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 new file mode 100644 index 0000000..6fa0bc0 --- /dev/null +++ b/watchlist.go @@ -0,0 +1,99 @@ +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 +) + +type WatchlistOrderType string + +const ( + WatchlistOrderAsc = "asc" + WatchlistOrderDesc = "desc" +) + +// WatchlistOptions represents options for receiving the user watchlist. +type WatchlistOptions struct { + // 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 + + // 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 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.Order == "" { + options.Order = WatchlistOrderDesc + } + values.Set("order", string(options.Order)) + 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"` + + 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"` +}