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 +}