diff --git a/common.go b/common.go index b76a8fe..3b3edf1 100644 --- a/common.go +++ b/common.go @@ -1,5 +1,10 @@ package crunchyroll +type BulkResult[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` +} + type Image struct { Height int `json:"height"` Source string `json:"source"` diff --git a/review.go b/review.go new file mode 100644 index 0000000..c1cb0db --- /dev/null +++ b/review.go @@ -0,0 +1,212 @@ +package crunchyroll + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// ReviewRating represents stars for a series rating from one to five. +type ReviewRating string + +const ( + OneStar ReviewRating = "s1" + TwoStars = "s2" + ThreeStars = "s3" + FourStars = "s4" + FiveStars = "s5" +) + +type ratingStar struct { + Displayed string `json:"displayed"` + Unit string `json:"unit"` + Percentage int `json:"percentage"` +} + +// Rating represents the overall rating of a series. +type Rating struct { + OneStar ratingStar `json:"1s"` + TwoStars ratingStar `json:"2s"` + ThreeStars ratingStar `json:"3s"` + FourStars ratingStar `json:"4s"` + FiveStars ratingStar `json:"5s"` + Average string `json:"average"` + Total int `json:"total"` + Rating string `json:"rating"` +} + +// Review is the interface which gets implemented by OwnerReview and UserReview. +type Review interface{} + +type review struct { + crunchy *Crunchyroll + + SeriesID string + + ReviewData struct { + ID string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Language LOCALE `json:"language"` + CreatedAt time.Time `json:"created_at"` + ModifiedAt time.Time `json:"modified_at"` + AuthoredReviews int `json:"authored_reviews"` + Spoiler bool `json:"spoiler"` + } `json:"review"` + AuthorRating ReviewRating `json:"author_rating"` + Author struct { + Username string `json:"username"` + Avatar string `json:"avatar"` + ID string `json:"ID"` + } `json:"author"` + Ratings struct { + Yes struct { + Displayed string `json:"displayed"` + Unit string `json:"unit"` + } `json:"yes"` + No struct { + Displayed string `json:"displayed"` + Unit string `json:"unit"` + } `json:"no"` + Total string `json:"total"` + // yes or no so basically a bool if set + Rating string `json:"rating"` + Reported bool `json:"reported"` + } `json:"ratings"` +} + +// OwnerReview is a series review which has been written from the current logged-in user. +type OwnerReview struct { + Review + + *review +} + +// Edit edits the review from the logged in account. +func (or *OwnerReview) Edit(title, content string, spoiler bool) error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/en-US/user/%s/review/series/%s", or.crunchy.Config.AccountID, or.SeriesID) + body, _ := json.Marshal(map[string]any{ + "title": title, + "body": content, + "spoiler": 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 := or.crunchy.requestFull(req) + if err != nil { + return err + } + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(or) + + return nil +} + +// Delete deletes the review from the logged in account. +func (or *OwnerReview) Delete() error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/en-US/user/%s/review/series/%s", or.crunchy.Config.AccountID, or.SeriesID) + _, err := or.crunchy.request(endpoint, http.MethodDelete) + return err +} + +// UserReview is a series review written from other crunchyroll users. +type UserReview struct { + Review + + *review +} + +// RateHelpful rates the review as helpful. A review can only be rated once +// as helpful (or not helpful) and this cannot be undone, so be careful. Use +// Rated to see if the review was already rated. +func (ur *UserReview) RateHelpful() error { + return ur.rate(true) +} + +// RateNotHelpful rates the review as not helpful. A review can only be rated +// once as helpful (or not helpful) and this cannot be undone, so be careful. +// Use Rated to see if the review was already rated. +func (ur *UserReview) RateNotHelpful() error { + return ur.rate(false) +} + +// Rated returns if the user already rated the review (with RateHelpful or +// RateNotHelpful). +func (ur *UserReview) Rated() bool { + return ur.Ratings.Rating != "" +} + +func (ur *UserReview) rate(positive bool) error { + if ur.Rated() { + var humanReadable string + switch ur.Ratings.Rating { + case "yes": + humanReadable = "helpful" + case "no": + humanReadable = "not helpful" + } + return fmt.Errorf("review is already rated as %s", humanReadable) + } + + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/user/%s/rating/review/%s", ur.crunchy.Config.AccountID, ur.ReviewData.ID) + var body []byte + if positive { + body, _ = json.Marshal(map[string]string{"rate": "yes"}) + } else { + body, _ = json.Marshal(map[string]string{"rate": "no"}) + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := ur.crunchy.requestFull(req) + if err != nil { + return err + } + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(&ur.Ratings) + + return nil +} + +// Report reports the review. Only works if the review hasn't been reported yet. +// See UserReview.Ratings.Reported if it is already reported. +func (ur *UserReview) Report() error { + if ur.Ratings.Reported { + return fmt.Errorf("review is already reported") + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/user/%s/report/review/%s", ur.crunchy.Config.AccountID, ur.ReviewData.ID) + _, err := ur.crunchy.request(endpoint, http.MethodPut) + if err != nil { + return err + } + + ur.Ratings.Reported = true + + return nil +} + +// RemoveReport removes the report request from the review. Only works if the user +// has reported the review. See UserReview.Ratings.Reported if it is already reported. +func (ur *UserReview) RemoveReport() error { + if !ur.Ratings.Reported { + return fmt.Errorf("review is not reported") + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/user/%s/report/review/%s", ur.crunchy.Config.AccountID, ur.ReviewData.ID) + _, err := ur.crunchy.request(endpoint, http.MethodDelete) + if err != nil { + return err + } + + ur.Ratings.Reported = false + + return nil +} diff --git a/video.go b/video.go index 810244f..e80e0f9 100644 --- a/video.go +++ b/video.go @@ -278,3 +278,126 @@ func (s *Series) Seasons() (seasons []*Season, err error) { } return } + +// Rating returns the series rating. +func (s *Series) Rating() (*Rating, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/user/%s/rating/series/%s", s.crunchy.Config.AccountID, s.ID) + resp, err := s.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + rating := &Rating{} + json.NewDecoder(resp.Body).Decode(rating) + + return rating, nil +} + +// ReviewSortType represents a sort type to sort Series.Reviews items after. +type ReviewSortType string + +const ( + ReviewSortNewest ReviewSortType = "newest" + ReviewSortOldest = "oldest" + ReviewSortHelpful = "helpful" +) + +// ReviewOptions represents options for fetching series reviews. +type ReviewOptions struct { + // Sort specifies how the items should be sorted. + Sort ReviewSortType `json:"sort"` + // Filter specified after which the returning items should be filtered. + Filter ReviewRating `json:"filter"` +} + +// Reviews returns user reviews for the series. +func (s *Series) Reviews(options ReviewOptions, page uint, size uint) (BulkResult[*UserReview], error) { + options, err := structDefaults(ReviewOptions{Sort: ReviewSortNewest}, options) + if err != nil { + return BulkResult[*UserReview]{}, err + } + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/%s/user/%s/review/series/%s/list?page=%d&page_size=%d&sort=%s&filter=%s", s.crunchy.Locale, s.crunchy.Config.AccountID, s.ID, page, size, options.Sort, options.Filter) + resp, err := s.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return BulkResult[*UserReview]{}, err + } + defer resp.Body.Close() + + var result BulkResult[*UserReview] + json.NewDecoder(resp.Body).Decode(&result) + + for _, review := range result.Items { + review.crunchy = s.crunchy + review.SeriesID = s.ID + } + + return result, nil +} + +// Rate rates the current series. +func (s *Series) Rate(rating ReviewRating) error { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/en-US/user/%s/review/series/%s", s.crunchy.Config.AccountID, s.ID) + body, _ := json.Marshal(map[string]string{"rating": string(rating)}) + req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + _, err = s.crunchy.requestFull(req) + return err +} + +// CreateReview creates a review for the current series with the logged-in account. +// Will fail if a review is already present. Check Series.HasOwnerReview if the account +// has already written a review. If this is the case, use Series.GetOwnerReview and user +// OwnerReview.Edit to edit the review. +func (s *Series) CreateReview(title, content string, spoiler bool) (*OwnerReview, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/en-US/user/%s/review/series/%s", s.crunchy.Config.AccountID, s.ID) + body, _ := json.Marshal(map[string]any{ + "title": title, + "body": content, + "spoiler": spoiler, + }) + req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + resp, err := s.crunchy.requestFull(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + review := &OwnerReview{} + json.NewDecoder(resp.Body).Decode(review) + review.crunchy = s.crunchy + review.SeriesID = s.ID + + return review, nil +} + +// GetOwnerReview returns the series review, written by the current logged-in account. +// Returns an error if no review was written yet. +func (s *Series) GetOwnerReview() (*OwnerReview, error) { + endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content-reviews/v2/en-US/user/%s/review/series/%s", s.crunchy.Config.AccountID, s.ID) + resp, err := s.crunchy.request(endpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + review := &OwnerReview{} + json.NewDecoder(resp.Body).Decode(review) + review.crunchy = s.crunchy + review.SeriesID = s.ID + + return review, nil +} + +// HasOwnerReview returns if the logged-in account has written a review for the series. +func (s *Series) HasOwnerReview() bool { + _, err := s.GetOwnerReview() + return err == nil +}