From 901bbf0706e22ee37ee4f940b274f40b412261b7 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 13 May 2022 19:22:17 +0200
Subject: [PATCH 01/87] Add update command
---
cmd/crunchyroll-go/cmd/update.go | 134 +++++++++++++++++++++++++++++++
crunchyroll-go.1 | 9 +++
2 files changed, 143 insertions(+)
create mode 100644 cmd/crunchyroll-go/cmd/update.go
diff --git a/cmd/crunchyroll-go/cmd/update.go b/cmd/crunchyroll-go/cmd/update.go
new file mode 100644
index 0000000..4c7aaad
--- /dev/null
+++ b/cmd/crunchyroll-go/cmd/update.go
@@ -0,0 +1,134 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/spf13/cobra"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+var (
+ updateInstallFlag bool
+)
+
+var updateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Check if updates are available",
+ Args: cobra.MaximumNArgs(0),
+
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return update()
+ },
+}
+
+func init() {
+ updateCmd.Flags().BoolVarP(&updateInstallFlag,
+ "install",
+ "i",
+ false,
+ "If set and a new version is available, the new version gets installed")
+
+ rootCmd.AddCommand(updateCmd)
+}
+
+func update() error {
+ var release map[string]interface{}
+
+ resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchyroll-go/releases/latest")
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
+ return err
+ }
+ releaseVersion := strings.TrimPrefix(release["tag_name"].(string), "v")
+
+ if Version == "development" {
+ out.Info("Development version, update service not available")
+ return nil
+ }
+
+ latestRelease := strings.SplitN(releaseVersion, ".", 4)
+ if len(latestRelease) != 3 {
+ return fmt.Errorf("latest tag name (%s) is not parsable", releaseVersion)
+ }
+
+ internalVersion := strings.SplitN(Version, ".", 4)
+ if len(internalVersion) != 3 {
+ return fmt.Errorf("internal version (%s) is not parsable", Version)
+ }
+
+ var hasUpdate bool
+ for i := 0; i < 3; i++ {
+ if latestRelease[i] < internalVersion[i] {
+ out.Info("Local version (%s) is newer than version in latest release (%s)", Version, releaseVersion)
+ return nil
+ } else if latestRelease[i] > internalVersion[i] {
+ hasUpdate = true
+ }
+ }
+
+ if !hasUpdate {
+ out.Info("Version is up-to-date")
+ return nil
+ }
+
+ out.Info("A new version is available (%s). Installed version is %s: https://github.com/ByteDream/crunchyroll-go/releases/tag/v%s", releaseVersion, Version, releaseVersion)
+
+ if updateInstallFlag {
+ if runtime.GOARCH != "amd64" {
+ return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
+ "You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOARCH)
+ }
+
+ var downloadFile string
+ switch runtime.GOOS {
+ case "linux":
+ yayCommand := exec.Command("pacman -Q crunchyroll-go")
+ if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
+ out.Info("crunchyroll-go was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
+ return nil
+ }
+ downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion)
+ case "darwin":
+ downloadFile = fmt.Sprintf("crunchy-v%s_darwin", releaseVersion)
+ case "windows":
+ downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
+ default:
+ return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
+ "You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOOS)
+ }
+
+ out.SetProgress("Updating executable %s", os.Args[0])
+
+ perms, err := os.Stat(os.Args[0])
+ if err != nil {
+ return err
+ }
+ os.Remove(os.Args[0])
+ executeFile, err := os.OpenFile(os.Args[0], os.O_CREATE|os.O_WRONLY, perms.Mode())
+ if err != nil {
+ return err
+ }
+ defer executeFile.Close()
+
+ resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchyroll-go/releases/download/v%s/%s", releaseVersion, downloadFile))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if _, err = io.Copy(executeFile, resp.Body); err != nil {
+ return err
+ }
+
+ out.StopProgress("Updated executable %s", os.Args[0])
+ }
+
+ return nil
+}
diff --git a/crunchyroll-go.1 b/crunchyroll-go.1
index 558aa23..576c291 100644
--- a/crunchyroll-go.1
+++ b/crunchyroll-go.1
@@ -13,6 +13,8 @@ crunchyroll-go login [\fB--persistent\fR] [\fB--session-id\fR \fISESSION_ID\fR]
crunchyroll-go download [\fB-a\fR \fIAUDIO\fR] [\fB-s\fR \fISUBTITLE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
.br
crunchyroll-go archive [\fB-l\fR \fILANGUAGE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-m\fR \fIMERGE BEHAVIOR\fR] [\fB-c\fR \fICOMPRESS\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
+.br
+crunchyroll-go update [\fB-i\fR \fIINSTALL\fR]
.SH DESCRIPTION
.TP
@@ -141,6 +143,13 @@ The video resolution. Can either be specified via the pixels (e.g. 1920x1080), t
\fB-g, --goroutines GOROUTINES\fR
Sets the number of parallel downloads for the segments the final video is made of. Default is the number of cores the computer has.
+.SH UPDATE COMMAND
+Checks if a newer version is available.
+.TP
+
+\fB-i, --install INSTALL\fR
+If given, the command tries to update the executable with the newer version (if a newer is available).
+
.SH URL OPTIONS
If you want to download only specific episode of a series, you could either pass every single episode url to the downloader (which is fine for 1 - 3 episodes) or use filtering.
It works pretty simple, just put a specific pattern surrounded by square brackets at the end of the url from the anime you want to download. A season and / or episode as well as a range from where to where episodes should be downloaded can be specified.
From 62735cf07cb8740db61fa886ddb02fb82e766952 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 19:28:05 +0200
Subject: [PATCH 02/87] Change request url for some request & regex
---
crunchyroll.go | 17 ++++++++---------
episode.go | 12 ++++--------
movie_listing.go | 18 ++++++------------
season.go | 16 +++++++---------
stream.go | 8 +++-----
video.go | 24 ++++++++----------------
6 files changed, 36 insertions(+), 59 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 6b1854f..60b4320 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -11,6 +11,7 @@ import (
"net/url"
"regexp"
"strconv"
+ "strings"
)
// LOCALE represents a locale / language.
@@ -33,7 +34,7 @@ const (
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 can be used to stop requests with Client.
Context context.Context
// Locale specifies in which language all results should be returned / requested.
Locale LOCALE
@@ -45,9 +46,10 @@ type Crunchyroll struct {
TokenType string
AccessToken string
+ Bucket string
+
CountryCode string
Premium bool
- Channel string
Policy string
Signature string
KeyPairID string
@@ -141,13 +143,7 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*
if user == nil {
return nil, errors.New("invalid session id, user is not logged in")
}
- if user.(map[string]interface{})["premium"] == "" {
- crunchy.Config.Premium = false
- crunchy.Config.Channel = "-"
- } else {
- crunchy.Config.Premium = true
- crunchy.Config.Channel = "crunchyroll"
- }
+ crunchy.Config.Premium = user.(map[string]interface{})["premium"] != ""
var etpRt string
for _, cookie := range resp.Cookies() {
@@ -200,6 +196,9 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*
}
cms := jsonBody["cms"].(map[string]interface{})
+ // / 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), "/")
crunchy.Config.Policy = cms["policy"].(string)
crunchy.Config.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
diff --git a/episode.go b/episode.go
index a25844c..3c9d38f 100644
--- a/episode.go
+++ b/episode.go
@@ -77,10 +77,8 @@ type Episode struct {
// EpisodeFromID returns an episode by its api id.
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ 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",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -194,10 +192,8 @@ func (e *Episode) Streams() ([]*Stream, error) {
return e.children, nil
}
- streams, err := fromVideoStreams(e.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- e.crunchy.Config.CountryCode,
- e.crunchy.Config.MaturityRating,
- e.crunchy.Config.Channel,
+ streams, err := fromVideoStreams(e.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ e.crunchy.Config.Bucket,
e.StreamID,
e.crunchy.Locale,
e.crunchy.Config.Signature,
diff --git a/movie_listing.go b/movie_listing.go
index 63d7fab..80b7b46 100644
--- a/movie_listing.go
+++ b/movie_listing.go
@@ -40,10 +40,8 @@ type MovieListing struct {
// MovieListingFromID returns a movie listing by its api id.
func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/movie_listing/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movie_listing/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -69,10 +67,8 @@ func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error)
// AudioLocale is same as Episode.AudioLocale.
func (ml *MovieListing) AudioLocale() (LOCALE, error) {
- resp, err := ml.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- ml.crunchy.Config.CountryCode,
- ml.crunchy.Config.MaturityRating,
- ml.crunchy.Config.Channel,
+ resp, err := ml.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ ml.crunchy.Config.Bucket,
ml.ID,
ml.crunchy.Locale,
ml.crunchy.Config.Signature,
@@ -90,10 +86,8 @@ func (ml *MovieListing) AudioLocale() (LOCALE, error) {
// Streams returns all streams which are available for the movie listing.
func (ml *MovieListing) Streams() ([]*Stream, error) {
- return fromVideoStreams(ml.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- ml.crunchy.Config.CountryCode,
- ml.crunchy.Config.MaturityRating,
- ml.crunchy.Config.Channel,
+ return fromVideoStreams(ml.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ ml.crunchy.Config.Bucket,
ml.ID,
ml.crunchy.Locale,
ml.crunchy.Config.Signature,
diff --git a/season.go b/season.go
index 825a816..b83d214 100644
--- a/season.go
+++ b/season.go
@@ -44,10 +44,8 @@ type Season struct {
// SeasonFromID returns a season by its api id.
func SeasonFromID(crunchy *Crunchyroll, id string) (*Season, error) {
- resp, err := crunchy.Client.Get(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/seasons/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ resp, err := crunchy.Client.Get(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -86,10 +84,8 @@ func (s *Season) Episodes() (episodes []*Episode, err error) {
return s.children, nil
}
- resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes?season_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- s.crunchy.Config.CountryCode,
- s.crunchy.Config.MaturityRating,
- s.crunchy.Config.Channel,
+ resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/episodes?season_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ s.crunchy.Config.Bucket,
s.ID,
s.crunchy.Locale,
s.crunchy.Config.Signature,
@@ -111,8 +107,10 @@ func (s *Season) Episodes() (episodes []*Episode, err error) {
}
if episode.Playback != "" {
streamHref := item.(map[string]interface{})["__links__"].(map[string]interface{})["streams"].(map[string]interface{})["href"].(string)
- if match := regexp.MustCompile(`(?m)^/cms/v2/\S+videos/(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
+ if match := regexp.MustCompile(`(?m)(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
episode.StreamID = match[0][1]
+ } else {
+ fmt.Println()
}
}
episodes = append(episodes, episode)
diff --git a/stream.go b/stream.go
index d8957e6..b1193ed 100644
--- a/stream.go
+++ b/stream.go
@@ -25,10 +25,8 @@ type Stream struct {
// StreamsFromID returns a stream by its api id.
func StreamsFromID(crunchy *Crunchyroll, id string) ([]*Stream, error) {
- return fromVideoStreams(crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ return fromVideoStreams(crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -105,7 +103,7 @@ func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream,
var id string
var formatType FormatType
href := jsonBody["__links__"].(map[string]interface{})["resource"].(map[string]interface{})["href"].(string)
- if match := regexp.MustCompile(`(?sm)^/cms/v2/\S+/crunchyroll/(\w+)/(\w+)$`).FindAllStringSubmatch(href, -1); len(match) > 0 {
+ if match := regexp.MustCompile(`(?sm)/(\w+)/(\w+)$`).FindAllStringSubmatch(href, -1); len(match) > 0 {
formatType = FormatType(match[0][1])
id = match[0][2]
}
diff --git a/video.go b/video.go
index 00b7734..8a7ee76 100644
--- a/video.go
+++ b/video.go
@@ -69,10 +69,8 @@ type Movie struct {
// MovieFromID returns a movie by its api id.
func MovieFromID(crunchy *Crunchyroll, id string) (*Movie, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/movies/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -102,10 +100,8 @@ func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
return m.children, nil
}
- resp, err := m.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- m.crunchy.Config.CountryCode,
- m.crunchy.Config.MaturityRating,
- m.crunchy.Config.Channel,
+ resp, err := m.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ m.crunchy.Config.Bucket,
m.ID,
m.crunchy.Locale,
m.crunchy.Config.Signature,
@@ -165,10 +161,8 @@ type Series struct {
// SeriesFromID returns a series by its api id.
func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.CountryCode,
- crunchy.Config.MaturityRating,
- crunchy.Config.Channel,
+ resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ crunchy.Config.Bucket,
id,
crunchy.Locale,
crunchy.Config.Signature,
@@ -198,10 +192,8 @@ func (s *Series) Seasons() (seasons []*Season, err error) {
return s.children, nil
}
- resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- s.crunchy.Config.CountryCode,
- s.crunchy.Config.MaturityRating,
- s.crunchy.Config.Channel,
+ resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
+ s.crunchy.Config.Bucket,
s.ID,
s.crunchy.Locale,
s.crunchy.Config.Signature,
From f51bdeaec7dcc05c4927b377ec113e1dbefc41c6 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 20:03:52 +0200
Subject: [PATCH 03/87] Add error return in some login failure cases (#30)
---
crunchyroll.go | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/crunchyroll.go b/crunchyroll.go
index 60b4320..d3b1ce8 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -97,6 +97,13 @@ func LoginWithCredentials(user string, password string, locale LOCALE, client *h
if loginResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to auth with credentials: %s", loginResp.Status)
+ } else {
+ var loginRespBody map[string]interface{}
+ json.NewDecoder(loginResp.Body).Decode(&loginRespBody)
+
+ if loginRespBody["error"].(bool) {
+ return nil, fmt.Errorf("an unexpected login error occoured: %s", loginRespBody["message"])
+ }
}
return LoginWithSessionID(sessionID, locale, client)
From 5b3466d06d555639aa27f3c13f54ac50b6e1740d Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 22:06:44 +0200
Subject: [PATCH 04/87] Add stream not available with non-premium error notice
---
stream.go | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/stream.go b/stream.go
index b1193ed..0fa54b9 100644
--- a/stream.go
+++ b/stream.go
@@ -80,8 +80,12 @@ func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream,
json.NewDecoder(resp.Body).Decode(&jsonBody)
if len(jsonBody) == 0 {
- // this may get thrown when the crunchyroll account has just a normal account and not one with premium
- return nil, errors.New("no stream available")
+ // this may get thrown when the crunchyroll account is just a normal account and not one with premium
+ if !crunchy.Config.Premium {
+ return nil, fmt.Errorf("no stream available, this might be the result of using a non-premium account")
+ } else {
+ return nil, errors.New("no stream available")
+ }
}
audioLocale := jsonBody["audio_locale"].(string)
From 6c476df24effcad9d6297c453535ce7f9bcf72db Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 22:07:44 +0200
Subject: [PATCH 05/87] Set beta url notice only if account is premium
---
cmd/crunchyroll-go/cmd/archive.go | 6 ++++--
cmd/crunchyroll-go/cmd/download.go | 6 ++++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/archive.go b/cmd/crunchyroll-go/cmd/archive.go
index 6f3f6a0..63e5d5a 100644
--- a/cmd/crunchyroll-go/cmd/archive.go
+++ b/cmd/crunchyroll-go/cmd/archive.go
@@ -182,8 +182,10 @@ func archive(urls []string) error {
episodes, err := archiveExtractEpisodes(url)
if err != nil {
out.StopProgress("Failed to parse url %d", i+1)
- out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ if crunchy.Config.Premium {
+ out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ }
return err
}
out.StopProgress("Parsed url %d", i+1)
diff --git a/cmd/crunchyroll-go/cmd/download.go b/cmd/crunchyroll-go/cmd/download.go
index 1ce202c..1254048 100644
--- a/cmd/crunchyroll-go/cmd/download.go
+++ b/cmd/crunchyroll-go/cmd/download.go
@@ -128,8 +128,10 @@ func download(urls []string) error {
episodes, err := downloadExtractEpisodes(url)
if err != nil {
out.StopProgress("Failed to parse url %d", i+1)
- out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ if crunchy.Config.Premium {
+ out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ }
return err
}
out.StopProgress("Parsed url %d", i+1)
From 0ffae4ddda15c72020343837367c6ea9be829dd9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 22:24:21 +0200
Subject: [PATCH 06/87] Add available function to check if episode streams are
available
---
episode.go | 7 +++++++
url.go | 5 +++++
utils/sort.go | 3 +++
3 files changed, 15 insertions(+)
diff --git a/episode.go b/episode.go
index 3c9d38f..7d477ce 100644
--- a/episode.go
+++ b/episode.go
@@ -112,6 +112,8 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
// 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
// this method on the first episode of the season.
+// Will fail if no streams are available, thus use Available to
+// prevent any misleading errors.
func (e *Episode) AudioLocale() (LOCALE, error) {
streams, err := e.Streams()
if err != nil {
@@ -120,6 +122,11 @@ func (e *Episode) AudioLocale() (LOCALE, error) {
return streams[0].AudioLocale, nil
}
+// Available returns if downloadable streams for this episodes are available.
+func (e *Episode) Available() bool {
+ return e.crunchy.Config.Premium || !e.IsPremiumOnly
+}
+
// 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/url.go b/url.go
index 32603fc..c84a3b9 100644
--- a/url.go
+++ b/url.go
@@ -49,6 +49,11 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
}
for _, episode := range episodes {
+ // if no episode streams are available, calling episode.AudioLocale
+ // will result in an unwanted error
+ if !episode.Available() {
+ continue
+ }
locale, err := episode.AudioLocale()
if err != nil {
return nil, err
diff --git a/utils/sort.go b/utils/sort.go
index a44717d..e06946c 100644
--- a/utils/sort.go
+++ b/utils/sort.go
@@ -52,6 +52,9 @@ func SortEpisodesByAudio(episodes []*crunchyroll.Episode) (map[crunchyroll.LOCAL
var wg sync.WaitGroup
var lock sync.Mutex
for _, episode := range episodes {
+ if !episode.Available() {
+ continue
+ }
episode := episode
wg.Add(1)
go func() {
From 43be2eee146550f9934231bbd1525cf735e41ab1 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 16 May 2022 22:25:33 +0200
Subject: [PATCH 07/87] Fix typo & add audio locale todo for non-premium
accounts
---
season.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/season.go b/season.go
index b83d214..f5ec08e 100644
--- a/season.go
+++ b/season.go
@@ -37,7 +37,7 @@ type Season struct {
AvailabilityNotes string `json:"availability_notes"`
- // the locales are always empty, idk why this may change in the future
+ // the locales are always empty, idk why, this may change in the future
AudioLocales []LOCALE
SubtitleLocales []LOCALE
}
@@ -71,6 +71,7 @@ func SeasonFromID(crunchy *Crunchyroll, id string) (*Season, error) {
// AudioLocale returns the audio locale of the season.
func (s *Season) AudioLocale() (LOCALE, error) {
+ // TODO: Add a function like Episode.Available to prevent this from returning an unwanted error when the account is non-premium
episodes, err := s.Episodes()
if err != nil {
return "", err
From f635bf1a2e1783e88fec015a25627d58c5698108 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 18 May 2022 21:47:17 +0200
Subject: [PATCH 08/87] Add available function to check if season streams are
available
---
episode.go | 4 ++--
season.go | 12 +++++++++++-
url.go | 1 +
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/episode.go b/episode.go
index 7d477ce..3a2e403 100644
--- a/episode.go
+++ b/episode.go
@@ -112,8 +112,8 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
// 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
// this method on the first episode of the season.
-// Will fail if no streams are available, thus use Available to
-// prevent any misleading errors.
+// Will fail if no streams are available, thus use Episode.Available
+// to prevent any misleading errors.
func (e *Episode) AudioLocale() (LOCALE, error) {
streams, err := e.Streams()
if err != nil {
diff --git a/season.go b/season.go
index f5ec08e..0bb8623 100644
--- a/season.go
+++ b/season.go
@@ -70,8 +70,9 @@ func SeasonFromID(crunchy *Crunchyroll, id string) (*Season, error) {
}
// AudioLocale returns the audio locale of the season.
+// Will fail if no streams are available, thus use Season.Available
+// to prevent any misleading errors.
func (s *Season) AudioLocale() (LOCALE, error) {
- // TODO: Add a function like Episode.Available to prevent this from returning an unwanted error when the account is non-premium
episodes, err := s.Episodes()
if err != nil {
return "", err
@@ -79,6 +80,15 @@ func (s *Season) AudioLocale() (LOCALE, error) {
return episodes[0].AudioLocale()
}
+// Available returns if downloadable streams for this season are available.
+func (s *Season) Available() (bool, error) {
+ episodes, err := s.Episodes()
+ if err != nil {
+ return false, err
+ }
+ return episodes[0].Available(), nil
+}
+
// Episodes returns all episodes which are available for the season.
func (s *Season) Episodes() (episodes []*Episode, err error) {
if s.children != nil {
diff --git a/url.go b/url.go
index c84a3b9..0f5bb25 100644
--- a/url.go
+++ b/url.go
@@ -21,6 +21,7 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
}
for _, season := range seasons {
if audio != nil {
+
locale, err := season.AudioLocale()
if err != nil {
return nil, err
From 7be803d485ca88994b1169b24e6c5adf88236c36 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 18 May 2022 21:54:31 +0200
Subject: [PATCH 09/87] Add extended error message if account is non-premium
---
url.go | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/url.go b/url.go
index 0f5bb25..86874eb 100644
--- a/url.go
+++ b/url.go
@@ -13,6 +13,7 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
}
var eps []*Episode
+ var notAvailableContinue bool
if series != nil {
seasons, err := series.Seasons()
@@ -21,6 +22,12 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
}
for _, season := range seasons {
if audio != nil {
+ if available, err := season.Available(); err != nil {
+ return nil, err
+ } else if !available {
+ notAvailableContinue = true
+ continue
+ }
locale, err := season.AudioLocale()
if err != nil {
@@ -53,6 +60,7 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
// if no episode streams are available, calling episode.AudioLocale
// will result in an unwanted error
if !episode.Available() {
+ notAvailableContinue = true
continue
}
locale, err := episode.AudioLocale()
@@ -77,7 +85,11 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
}
if len(eps) == 0 {
- return nil, fmt.Errorf("could not find any matching episode")
+ if notAvailableContinue {
+ return nil, fmt.Errorf("could not find any matching episode which is accessable with an non-premium account")
+ } else {
+ return nil, fmt.Errorf("could not find any matching episode")
+ }
}
return eps, nil
From d4e095a576f1617082ad9952b626c916800e5382 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 18 May 2022 21:56:29 +0200
Subject: [PATCH 10/87] Fix typo
---
url.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/url.go b/url.go
index 86874eb..95f3951 100644
--- a/url.go
+++ b/url.go
@@ -86,7 +86,7 @@ func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Ep
if len(eps) == 0 {
if notAvailableContinue {
- return nil, fmt.Errorf("could not find any matching episode which is accessable with an non-premium account")
+ return nil, fmt.Errorf("could not find any matching episode which is accessable with a non-premium account")
} else {
return nil, fmt.Errorf("could not find any matching episode")
}
From 608e03bc11925c9bb78ad6a0b4efbf4137a34c6e Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 18 May 2022 22:21:49 +0200
Subject: [PATCH 11/87] Add better update output
---
cmd/crunchyroll-go/cmd/update.go | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/update.go b/cmd/crunchyroll-go/cmd/update.go
index 4c7aaad..c8512e6 100644
--- a/cmd/crunchyroll-go/cmd/update.go
+++ b/cmd/crunchyroll-go/cmd/update.go
@@ -63,10 +63,12 @@ func update() error {
return fmt.Errorf("internal version (%s) is not parsable", Version)
}
+ out.Info("Installed version is %s", Version)
+
var hasUpdate bool
for i := 0; i < 3; i++ {
if latestRelease[i] < internalVersion[i] {
- out.Info("Local version (%s) is newer than version in latest release (%s)", Version, releaseVersion)
+ out.Info("Local version is newer than version in latest release (%s)", releaseVersion)
return nil
} else if latestRelease[i] > internalVersion[i] {
hasUpdate = true
@@ -78,7 +80,7 @@ func update() error {
return nil
}
- out.Info("A new version is available (%s). Installed version is %s: https://github.com/ByteDream/crunchyroll-go/releases/tag/v%s", releaseVersion, Version, releaseVersion)
+ out.Info("A new version is available (%s): https://github.com/ByteDream/crunchyroll-go/releases/tag/v%s", releaseVersion, releaseVersion)
if updateInstallFlag {
if runtime.GOARCH != "amd64" {
From a4ec163275ab6fb6961cc6e208d959ee02399a80 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 20 May 2022 22:57:07 +0200
Subject: [PATCH 12/87] Add basic encrypted login credentials support
---
cmd/crunchyroll-go/cmd/login.go | 75 ++++++++++++++++++++++++++++---
cmd/crunchyroll-go/cmd/unix.go | 48 ++++++++++++++++++++
cmd/crunchyroll-go/cmd/utils.go | 52 ++++++++++++++++++---
cmd/crunchyroll-go/cmd/windows.go | 41 +++++++++++++++++
4 files changed, 205 insertions(+), 11 deletions(-)
create mode 100644 cmd/crunchyroll-go/cmd/unix.go
create mode 100644 cmd/crunchyroll-go/cmd/windows.go
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index c9fc923..565dac0 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -1,15 +1,22 @@
package cmd
import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
"fmt"
"github.com/ByteDream/crunchyroll-go/v2"
"github.com/spf13/cobra"
+ "io"
"os"
"path/filepath"
)
var (
loginPersistentFlag bool
+ loginEncryptFlag bool
loginSessionIDFlag bool
)
@@ -33,6 +40,10 @@ func init() {
"persistent",
false,
"If the given credential should be stored persistent")
+ loginCmd.Flags().BoolVar(&loginEncryptFlag,
+ "encrypt",
+ false,
+ "Encrypt the given credentials (won't do anything if --session-id is given)")
loginCmd.Flags().BoolVar(&loginSessionIDFlag,
"session-id",
@@ -49,20 +60,74 @@ func loginCredentials(user, password string) error {
return err
}
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.SessionID), 0600); err != nil {
+ return err
+ }
+
if loginPersistentFlag {
if configDir, err := os.UserConfigDir(); err != nil {
return fmt.Errorf("could not save credentials persistent: %w", err)
} else {
+ var credentials []byte
+
+ if loginEncryptFlag {
+ var passwd []byte
+
+ for {
+ fmt.Print("Enter password: ")
+ passwd, err = readLineSilent()
+ if err != nil {
+ return err
+ }
+ fmt.Println()
+
+ fmt.Print("Enter password again: ")
+ repasswd, err := readLineSilent()
+ if err != nil {
+ return err
+ }
+ fmt.Println()
+
+ if !bytes.Equal(passwd, repasswd) {
+ fmt.Println("Passwords does not match, try again")
+ continue
+ }
+
+ hashedPassword := sha256.Sum256(passwd)
+ block, err := aes.NewCipher(hashedPassword[:])
+ if err != nil {
+ out.Err("Failed to create block: %w", err)
+ os.Exit(1)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ out.Err("Failed to create gcm: %w", err)
+ os.Exit(1)
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ out.Err("Failed to fill nonce: %w", err)
+ os.Exit(1)
+ }
+
+ b := gcm.Seal(nonce, nonce, []byte(fmt.Sprintf("%s\n%s", user, password)), nil)
+ credentials = append([]byte("aes:"), b...)
+
+ break
+ }
+ } else {
+ credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
+ }
+
os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(fmt.Sprintf("%s\n%s", user, password)), 0600); err != nil {
+ if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), credentials, 0600); err != nil {
return err
}
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ if !loginEncryptFlag {
+ out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ }
}
}
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.SessionID), 0600); err != nil {
- return err
- }
if !loginPersistentFlag {
out.Info("Due to security reasons, you have to login again on the next reboot")
diff --git a/cmd/crunchyroll-go/cmd/unix.go b/cmd/crunchyroll-go/cmd/unix.go
new file mode 100644
index 0000000..962088f
--- /dev/null
+++ b/cmd/crunchyroll-go/cmd/unix.go
@@ -0,0 +1,48 @@
+//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
+
+package cmd
+
+import (
+ "bufio"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+// https://github.com/bgentry/speakeasy/blob/master/speakeasy_unix.go
+var stty string
+
+func init() {
+ var err error
+ if stty, err = exec.LookPath("stty"); err != nil {
+ panic(err)
+ }
+}
+
+func readLineSilent() ([]byte, error) {
+ pid, err := setEcho(false)
+ if err != nil {
+ return nil, err
+ }
+ defer setEcho(true)
+
+ syscall.Wait4(pid, nil, 0, nil)
+
+ l, _, err := bufio.NewReader(os.Stdin).ReadLine()
+ return l, err
+}
+
+func setEcho(on bool) (pid int, err error) {
+ fds := []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()}
+
+ if on {
+ pid, err = syscall.ForkExec(stty, []string{"stty", "echo"}, &syscall.ProcAttr{Files: fds})
+ } else {
+ pid, err = syscall.ForkExec(stty, []string{"stty", "-echo"}, &syscall.ProcAttr{Files: fds})
+ }
+
+ if err != nil {
+ return 0, err
+ }
+ return
+}
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 2a632ff..d7eba3d 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -1,6 +1,9 @@
package cmd
import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha256"
"fmt"
"github.com/ByteDream/crunchyroll-go/v2"
"github.com/ByteDream/crunchyroll-go/v2/utils"
@@ -167,13 +170,49 @@ func loadCrunchy() {
}
split := strings.SplitN(string(body), "\n", 2)
if len(split) == 1 || split[1] == "" {
- split[0] = url.QueryEscape(split[0])
- if crunchy, err = crunchyroll.LoginWithSessionID(split[0], systemLocale(true), client); err != nil {
- out.StopProgress(err.Error())
- os.Exit(1)
+ if strings.HasPrefix(split[0], "aes:") {
+ encrypted := body[4:]
+
+ out.StopProgress("Credentials are encrypted")
+ fmt.Print("Enter password to encrypt it: ")
+ passwd, err := readLineSilent()
+ fmt.Println()
+ if err != nil {
+ out.Err("Failed to read password; %w", err)
+ os.Exit(1)
+ }
+ out.SetProgress("Logging in")
+
+ hashedPassword := sha256.Sum256(passwd)
+ block, err := aes.NewCipher(hashedPassword[:])
+ if err != nil {
+ out.Err("Failed to create block: %w", err)
+ os.Exit(1)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ out.Err("Failed to create gcm: %w", err)
+ os.Exit(1)
+ }
+ nonce, c := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():]
+
+ b, err := gcm.Open(nil, nonce, c, nil)
+ if err != nil {
+ out.StopProgress("Invalid password")
+ os.Exit(1)
+ }
+ split = strings.SplitN(string(b), "\n", 2)
+ } else {
+ split[0] = url.QueryEscape(split[0])
+ if crunchy, err = crunchyroll.LoginWithSessionID(split[0], systemLocale(true), client); err != nil {
+ out.StopProgress(err.Error())
+ os.Exit(1)
+ }
+ out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
}
- out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
- } else {
+ }
+
+ if len(split) == 2 {
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
@@ -183,6 +222,7 @@ func loadCrunchy() {
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.SessionID), 0600)
}
+
out.StopProgress("Logged in")
return
}
diff --git a/cmd/crunchyroll-go/cmd/windows.go b/cmd/crunchyroll-go/cmd/windows.go
new file mode 100644
index 0000000..d6eecb1
--- /dev/null
+++ b/cmd/crunchyroll-go/cmd/windows.go
@@ -0,0 +1,41 @@
+//go:build windows
+
+package cmd
+
+import (
+ "bufio"
+ "os"
+ "syscall"
+)
+
+// https://github.com/bgentry/speakeasy/blob/master/speakeasy_windows.go
+func readLineSilent() ([]byte, error) {
+ var oldMode uint32
+
+ if err := syscall.GetConsoleMode(syscall.Stdin, &oldMode); err != nil {
+ return nil, err
+ }
+
+ newMode := oldMode &^ 0x0004
+
+ err := setConsoleMode(syscall.Stdin, newMode)
+ defer setConsoleMode(syscall.Stdin, oldMode)
+
+ if err != nil {
+ return nil, err
+ }
+
+ l, _, err := bufio.NewReader(os.Stdin).ReadLine()
+ if err != nil {
+ return nil, err
+ }
+ return l, err
+}
+
+func setConsoleMode(console syscall.Handle, mode uint32) error {
+ dll := syscall.MustLoadDLL("kernel32")
+ proc := dll.MustFindProc("SetConsoleMode")
+ _, _, err := proc.Call(uintptr(console), uintptr(mode))
+
+ return err
+}
From b78d6a7871f4f177fdeb6729a2a011a3f48b7747 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sat, 21 May 2022 00:29:03 +0200
Subject: [PATCH 13/87] Change default Makefile version variable to development
---
Makefile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 3dd5c3e..9b96538 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-VERSION=2.2.2
+VERSION=development
BINARY_NAME=crunchy
VERSION_BINARY_NAME=$(BINARY_NAME)-v$(VERSION)
From eb2414d0120bd1067b9324d4b4c31d4dcea9a568 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sat, 21 May 2022 21:38:45 +0200
Subject: [PATCH 14/87] Update module version to v3
---
Makefile | 8 ++++----
README.md | 4 ++--
cmd/crunchyroll-go/cmd/archive.go | 4 ++--
cmd/crunchyroll-go/cmd/download.go | 4 ++--
cmd/crunchyroll-go/cmd/login.go | 2 +-
cmd/crunchyroll-go/cmd/root.go | 2 +-
cmd/crunchyroll-go/cmd/utils.go | 4 ++--
cmd/crunchyroll-go/main.go | 2 +-
go.mod | 2 +-
utils/locale.go | 2 +-
utils/sort.go | 2 +-
11 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/Makefile b/Makefile
index 9b96538..a747c1d 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ DESTDIR=
PREFIX=/usr
build:
- go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(BINARY_NAME) cmd/crunchyroll-go/main.go
+ go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(BINARY_NAME) cmd/crunchyroll-go/main.go
clean:
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
@@ -24,8 +24,8 @@ uninstall:
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
release:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux cmd/crunchyroll-go/main.go
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe cmd/crunchyroll-go/main.go
- CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin cmd/crunchyroll-go/main.go
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux cmd/crunchyroll-go/main.go
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe cmd/crunchyroll-go/main.go
+ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin cmd/crunchyroll-go/main.go
strip $(VERSION_BINARY_NAME)_linux
diff --git a/README.md b/README.md
index c83493c..f875018 100644
--- a/README.md
+++ b/README.md
@@ -205,10 +205,10 @@ These flags you can use across every sub-command:
Download the library via `go get`
```shell
-$ go get github.com/ByteDream/crunchyroll-go/v2
+$ go get github.com/ByteDream/crunchyroll-go/v3
```
-The documentation is available on [pkg.go.dev](https://pkg.go.dev/github.com/ByteDream/crunchyroll-go/v2).
+The documentation is available on [pkg.go.dev](https://pkg.go.dev/github.com/ByteDream/crunchyroll-go/v3).
Examples how to use the library and some features of it are described in the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Library).
diff --git a/cmd/crunchyroll-go/cmd/archive.go b/cmd/crunchyroll-go/cmd/archive.go
index 6dcf61b..d4a7e48 100644
--- a/cmd/crunchyroll-go/cmd/archive.go
+++ b/cmd/crunchyroll-go/cmd/archive.go
@@ -8,8 +8,8 @@ import (
"compress/gzip"
"context"
"fmt"
- "github.com/ByteDream/crunchyroll-go/v2"
- "github.com/ByteDream/crunchyroll-go/v2/utils"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"io"
diff --git a/cmd/crunchyroll-go/cmd/download.go b/cmd/crunchyroll-go/cmd/download.go
index 92b6027..3dddf48 100644
--- a/cmd/crunchyroll-go/cmd/download.go
+++ b/cmd/crunchyroll-go/cmd/download.go
@@ -3,8 +3,8 @@ package cmd
import (
"context"
"fmt"
- "github.com/ByteDream/crunchyroll-go/v2"
- "github.com/ByteDream/crunchyroll-go/v2/utils"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"math"
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index c9fc923..4bee3b0 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -2,7 +2,7 @@ package cmd
import (
"fmt"
- "github.com/ByteDream/crunchyroll-go/v2"
+ "github.com/ByteDream/crunchyroll-go/v3"
"github.com/spf13/cobra"
"os"
"path/filepath"
diff --git a/cmd/crunchyroll-go/cmd/root.go b/cmd/crunchyroll-go/cmd/root.go
index 82ee133..02873f1 100644
--- a/cmd/crunchyroll-go/cmd/root.go
+++ b/cmd/crunchyroll-go/cmd/root.go
@@ -3,7 +3,7 @@ package cmd
import (
"context"
"fmt"
- "github.com/ByteDream/crunchyroll-go/v2"
+ "github.com/ByteDream/crunchyroll-go/v3"
"github.com/spf13/cobra"
"net/http"
"os"
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 2a632ff..2fddd60 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -2,8 +2,8 @@ package cmd
import (
"fmt"
- "github.com/ByteDream/crunchyroll-go/v2"
- "github.com/ByteDream/crunchyroll-go/v2/utils"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
"net/http"
"net/url"
"os"
diff --git a/cmd/crunchyroll-go/main.go b/cmd/crunchyroll-go/main.go
index a502afb..0ced8aa 100644
--- a/cmd/crunchyroll-go/main.go
+++ b/cmd/crunchyroll-go/main.go
@@ -1,7 +1,7 @@
package main
import (
- "github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd"
+ "github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd"
)
func main() {
diff --git a/go.mod b/go.mod
index 9a38468..5c64ddf 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/ByteDream/crunchyroll-go/v2
+module github.com/ByteDream/crunchyroll-go/v3
go 1.18
diff --git a/utils/locale.go b/utils/locale.go
index 537b165..85a8650 100644
--- a/utils/locale.go
+++ b/utils/locale.go
@@ -1,7 +1,7 @@
package utils
import (
- "github.com/ByteDream/crunchyroll-go/v2"
+ "github.com/ByteDream/crunchyroll-go/v3"
)
// AllLocales is an array of all available locales.
diff --git a/utils/sort.go b/utils/sort.go
index a44717d..eacb96b 100644
--- a/utils/sort.go
+++ b/utils/sort.go
@@ -1,7 +1,7 @@
package utils
import (
- "github.com/ByteDream/crunchyroll-go/v2"
+ "github.com/ByteDream/crunchyroll-go/v3"
"sort"
"strconv"
"strings"
From 638689ee327f4b5df4571baa0afd9763b222a9ee Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 27 May 2022 15:13:15 +0200
Subject: [PATCH 15/87] Add new login method & deprecated login with session id
---
crunchyroll.go | 298 +++++++++++++++++++++++++------------------------
1 file changed, 151 insertions(+), 147 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 1dafee7..0aa4a2e 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -10,6 +10,7 @@ import (
"net/url"
"regexp"
"strconv"
+ "strings"
)
// LOCALE represents a locale / language.
@@ -36,14 +37,17 @@ type Crunchyroll struct {
Context context.Context
// Locale specifies in which language all results should be returned / requested.
Locale LOCALE
- // SessionID is the crunchyroll session id which was used for authentication.
- SessionID string
+ // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
+ // this struct in v2 and below).
+ EtpRt string
// Config stores parameters which are needed by some api calls.
Config struct {
TokenType string
AccessToken string
+ Bucket string
+
CountryCode string
Premium bool
Channel string
@@ -59,101 +63,38 @@ type Crunchyroll struct {
cache bool
}
-// LoginWithCredentials logs in via crunchyroll username or email and password.
-func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- sessionIDEndpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?version=1.0&access_token=%s&device_type=%s&device_id=%s",
- "LNDJgOit5yaRIWN", "com.crunchyroll.windows.desktop", "Az2srGnChW65fuxYz2Xxl1GcZQgtGgI")
- sessResp, err := client.Get(sessionIDEndpoint)
- if err != nil {
- return nil, err
- }
- defer sessResp.Body.Close()
-
- if sessResp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to start session for credentials login: %s", sessResp.Status)
- }
-
- var data map[string]interface{}
- body, _ := io.ReadAll(sessResp.Body)
- if err = json.Unmarshal(body, &data); err != nil {
- return nil, fmt.Errorf("failed to parse start session with credentials response: %w", err)
- }
-
- sessionID := data["data"].(map[string]interface{})["session_id"].(string)
-
- loginEndpoint := "https://api.crunchyroll.com/login.0.json"
- authValues := url.Values{}
- authValues.Set("session_id", sessionID)
- authValues.Set("account", user)
- authValues.Set("password", password)
- loginResp, err := client.Post(loginEndpoint, "application/x-www-form-urlencoded", bytes.NewBufferString(authValues.Encode()))
- if err != nil {
- return nil, err
- }
- defer loginResp.Body.Close()
-
- if loginResp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to auth with credentials: %s", loginResp.Status)
- } else {
- var loginRespBody map[string]interface{}
- json.NewDecoder(loginResp.Body).Decode(&loginRespBody)
-
- if loginRespBody["error"].(bool) {
- return nil, fmt.Errorf("an unexpected login error occoured: %s", loginRespBody["message"])
- }
- }
-
- return LoginWithSessionID(sessionID, locale, client)
+type loginResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ TokenType string `json:"token_type"`
+ Scope string `json:"scope"`
+ Country string `json:"country"`
+ AccountID string `json:"account_id"`
}
-// LoginWithSessionID logs in via a crunchyroll session id.
-// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com.
-func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- crunchy := &Crunchyroll{
- Client: client,
- Context: context.Background(),
- Locale: locale,
- SessionID: sessionID,
- cache: true,
- }
- var endpoint string
- var err error
- var resp *http.Response
- var jsonBody map[string]interface{}
+// LoginWithCredentials logs in via crunchyroll username or email and password.
+func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
+ endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
+ values := url.Values{}
+ values.Set("username", user)
+ values.Set("password", password)
+ values.Set("grant_type", "password")
- // start session
- endpoint = fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
- sessionID)
- resp, err = client.Get(endpoint)
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(values.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Basic aHJobzlxM2F3dnNrMjJ1LXRzNWE6cHROOURteXRBU2Z6QjZvbXVsSzh6cUxzYTczVE1TY1k=")
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := request(req, client)
if err != nil {
return nil, err
}
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to start session: %s", resp.Status)
- }
-
- 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{})
-
- crunchy.Config.CountryCode = data["country_code"].(string)
- user := data["user"]
- if user == nil {
- return nil, fmt.Errorf("invalid session id, user is not logged in")
- }
- if user.(map[string]interface{})["premium"] == "" {
- crunchy.Config.Premium = false
- crunchy.Config.Channel = "-"
- } else {
- crunchy.Config.Premium = true
- crunchy.Config.Channel = "crunchyroll"
- }
+ var loginResp loginResponse
+ json.NewDecoder(resp.Body).Decode(&loginResp)
var etpRt string
for _, cookie := range resp.Cookies() {
@@ -163,83 +104,164 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*
}
}
- // token
- endpoint = "https://beta-api.crunchyroll.com/auth/v1/token"
+ return postLogin(loginResp, etpRt, locale, client)
+}
+
+// LoginWithSessionID logs in via a crunchyroll session id.
+// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com.
+//
+// Deprecated: Login via session id caused some trouble in the past (e.g. #15 or #30) which resulted in
+// login not working. Use LoginWithEtpRt instead. EtpRt practically the crunchyroll beta equivalent to
+// a session id.
+// The method will stay in the library until session id login is removed completely or login with it
+// does not work for a longer period of time.
+func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
+ endpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
+ sessionID)
+ resp, err := client.Get(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]any
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ var etpRt string
+ for _, cookie := range resp.Cookies() {
+ if cookie.Name == "etp_rt" {
+ etpRt = cookie.Value
+ break
+ }
+ }
+
+ return LoginWithEtpRt(etpRt, locale, client)
+}
+
+// LoginWithEtpRt logs in via the crunchyroll etp rt cookie. This cookie is the crunchyroll beta
+// equivalent to the classic session id.
+// The etp_rt cookie is automatically set when visiting https://beta.crunchyroll.com. Note that you
+// need a crunchyroll account to access it.
+func LoginWithEtpRt(etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
+ endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
grantType := url.Values{}
grantType.Set("grant_type", "etp_rt_cookie")
- authRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(grantType.Encode()))
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(grantType.Encode()))
if err != nil {
return nil, err
}
- authRequest.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
- authRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded")
- authRequest.AddCookie(&http.Cookie{
- Name: "session_id",
- Value: sessionID,
- })
- authRequest.AddCookie(&http.Cookie{
+ req.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(&http.Cookie{
Name: "etp_rt",
Value: etpRt,
})
+ resp, err := request(req, client)
+ if err != nil {
+ return nil, err
+ }
- resp, err = client.Do(authRequest)
+ var loginResp loginResponse
+ json.NewDecoder(resp.Body).Decode(&loginResp)
+
+ return postLogin(loginResp, etpRt, locale, client)
+}
+
+func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
+ crunchy := &Crunchyroll{
+ Client: client,
+ Context: context.Background(),
+ Locale: locale,
+ EtpRt: etpRt,
+ cache: true,
+ }
+
+ crunchy.Config.TokenType = loginResp.TokenType
+ crunchy.Config.AccessToken = loginResp.AccessToken
+ crunchy.Config.AccountID = loginResp.AccountID
+
+ var jsonBody map[string]any
+
+ endpoint := "https://beta-api.crunchyroll.com/index/v2"
+ resp, err := crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'token' response: %w", err)
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+ cms := jsonBody["cms"].(map[string]any)
+ crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
+ if strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") {
+ crunchy.Config.Premium = true
+ crunchy.Config.Channel = "crunchyroll"
+ } else {
+ crunchy.Config.Premium = false
+ crunchy.Config.Channel = "-"
}
- crunchy.Config.TokenType = jsonBody["token_type"].(string)
- crunchy.Config.AccessToken = jsonBody["access_token"].(string)
-
- // index
- endpoint = "https://beta-api.crunchyroll.com/index/v2"
- resp, err = crunchy.request(endpoint)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'index' response: %w", err)
- }
- cms := jsonBody["cms"].(map[string]interface{})
-
crunchy.Config.Policy = cms["policy"].(string)
crunchy.Config.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
- // me
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me"
resp, err = crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
- }
-
- crunchy.Config.AccountID = jsonBody["account_id"].(string)
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
crunchy.Config.ExternalID = jsonBody["external_id"].(string)
- //profile
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/profile"
resp, err = crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
- }
-
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string)
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)
+ }()
+
+ if buf.Len() != 0 {
+ var errMap map[string]any
+
+ if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil {
+ return nil, fmt.Errorf("invalid json response: %w", err)
+ }
+
+ if val, ok := errMap["error"]; ok {
+ if errorAsString, ok := val.(string); ok {
+ if code, ok := errMap["code"].(string); ok {
+ return nil, fmt.Errorf("error for endpoint %s (%d): %s - %s", req.URL.String(), resp.StatusCode, errorAsString, code)
+ }
+ return nil, 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)
+ }
+ }
+ return resp, err
+}
+
// request is a base function which handles api requests.
func (c *Crunchyroll) request(endpoint string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
@@ -248,25 +270,7 @@ func (c *Crunchyroll) request(endpoint string) (*http.Response, error) {
}
req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
- resp, err := c.Client.Do(req)
- if err == nil {
- defer resp.Body.Close()
- bodyAsBytes, _ := io.ReadAll(resp.Body)
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusUnauthorized {
- return nil, fmt.Errorf("invalid access token")
- } else {
- var errStruct struct {
- Message string `json:"message"`
- }
- json.NewDecoder(bytes.NewBuffer(bodyAsBytes)).Decode(&errStruct)
- if errStruct.Message != "" {
- return nil, fmt.Errorf(errStruct.Message)
- }
- }
- resp.Body = io.NopCloser(bytes.NewBuffer(bodyAsBytes))
- }
- return resp, err
+ return request(req, c.Client)
}
// IsCaching returns if data gets cached or not.
From c94ce0fb59dddb29bcbce4ad7e1e5b2dc1814b84 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 27 May 2022 15:58:28 +0200
Subject: [PATCH 16/87] Fix country code not set
---
crunchyroll.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/crunchyroll.go b/crunchyroll.go
index 0aa4a2e..06a28ec 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -180,6 +180,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
crunchy.Config.TokenType = loginResp.TokenType
crunchy.Config.AccessToken = loginResp.AccessToken
crunchy.Config.AccountID = loginResp.AccountID
+ crunchy.Config.CountryCode = loginResp.Country
var jsonBody map[string]any
From 0780a2a2bceb42d83fb533540de247718585e6a9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 27 May 2022 15:59:00 +0200
Subject: [PATCH 17/87] Change login type from session id to etp rt
---
cmd/crunchyroll-go/cmd/login.go | 46 ++++++++++++++++++++++++++++++---
cmd/crunchyroll-go/cmd/utils.go | 14 +++++-----
2 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index 4bee3b0..28a784e 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -12,6 +12,7 @@ var (
loginPersistentFlag bool
loginSessionIDFlag bool
+ loginEtpRtFlag bool
)
var loginCmd = &cobra.Command{
@@ -22,6 +23,8 @@ var loginCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
if loginSessionIDFlag {
return loginSessionID(args[0])
+ } else if loginEtpRtFlag {
+ return loginEtpRt(args[0])
} else {
return loginCredentials(args[0], args[1])
}
@@ -38,6 +41,10 @@ func init() {
"session-id",
false,
"Use a session id to login instead of username and password")
+ loginCmd.Flags().BoolVar(&loginEtpRtFlag,
+ "etp-rt",
+ false,
+ "Use a etp rt cookie to login instead of username and password")
rootCmd.AddCommand(loginCmd)
}
@@ -60,7 +67,7 @@ func loginCredentials(user, password string) error {
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
}
}
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.SessionID), 0600); err != nil {
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
return err
}
@@ -73,7 +80,38 @@ func loginCredentials(user, password string) error {
func loginSessionID(sessionID string) error {
out.Debug("Logging in via session id")
- if _, err := crunchyroll.LoginWithSessionID(sessionID, systemLocale(false), client); err != nil {
+ var c *crunchyroll.Crunchyroll
+ var err error
+ if c, err = crunchyroll.LoginWithSessionID(sessionID, systemLocale(false), client); err != nil {
+ out.Err(err.Error())
+ os.Exit(1)
+ }
+
+ if loginPersistentFlag {
+ if configDir, err := os.UserConfigDir(); err != nil {
+ return fmt.Errorf("could not save credentials persistent: %w", err)
+ } else {
+ os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
+ if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
+ return err
+ }
+ out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ }
+ }
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
+ return err
+ }
+
+ if !loginPersistentFlag {
+ out.Info("Due to security reasons, you have to login again on the next reboot")
+ }
+
+ return nil
+}
+
+func loginEtpRt(etpRt string) error {
+ out.Debug("Logging in via etp rt")
+ if _, err := crunchyroll.LoginWithEtpRt(etpRt, systemLocale(false), client); err != nil {
out.Err(err.Error())
os.Exit(1)
}
@@ -84,13 +122,13 @@ func loginSessionID(sessionID string) error {
return fmt.Errorf("could not save credentials persistent: %w", err)
} else {
os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(sessionID), 0600); err != nil {
+ if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(etpRt), 0600); err != nil {
return err
}
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
}
}
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(sessionID), 0600); err != nil {
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil {
return err
}
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 2fddd60..86691ab 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -147,10 +147,10 @@ func loadCrunchy() {
out.StopProgress("Failed to read login information: %v", err)
os.Exit(1)
}
- if crunchy, err = crunchyroll.LoginWithSessionID(url.QueryEscape(string(body)), systemLocale(true), client); err != nil {
- out.Debug("Failed to login with temp session id: %w", err)
+ if crunchy, err = crunchyroll.LoginWithEtpRt(url.QueryEscape(string(body)), systemLocale(true), client); err != nil {
+ out.Debug("Failed to login with temp etp rt: %w", err)
} else {
- out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
+ out.Debug("Logged in with etp rt %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
out.StopProgress("Logged in")
return
@@ -168,20 +168,20 @@ func loadCrunchy() {
split := strings.SplitN(string(body), "\n", 2)
if len(split) == 1 || split[1] == "" {
split[0] = url.QueryEscape(split[0])
- if crunchy, err = crunchyroll.LoginWithSessionID(split[0], systemLocale(true), client); err != nil {
+ if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
}
- out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
+ out.Debug("Logged in with etp rt %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
} else {
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
}
- out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", crunchy.SessionID)
+ out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", crunchy.EtpRt)
// the session id is written to a temp file to reduce the amount of re-logging in.
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
- os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.SessionID), 0600)
+ os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
}
out.StopProgress("Logged in")
return
From b4ba8c45996eeb3fce5484cd7af23199b868bff6 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 27 May 2022 16:00:08 +0200
Subject: [PATCH 18/87] Bump go CI version to 1.18
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44c7ac6..7c54dd3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
- go-version: 1.16
+ go-version: 1.18
- name: Build
run: go build -v cmd/crunchyroll-go/main.go
From 15373ed7d6b689e900ba3c44cf35376048bed679 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 27 May 2022 16:03:37 +0200
Subject: [PATCH 19/87] Fix typo
---
cmd/crunchyroll-go/cmd/utils.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 86691ab..aa98399 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -179,7 +179,7 @@ func loadCrunchy() {
os.Exit(1)
}
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", crunchy.EtpRt)
- // the session id is written to a temp file to reduce the amount of re-logging in.
+ // the etp rt is written to a temp file to reduce the amount of re-logging in.
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
}
From 08c46e50bb1cdc6bc23dc73efb94fd5dc0c1cc6a Mon Sep 17 00:00:00 2001
From: IchBinLeoon <50367411+IchBinLeoon@users.noreply.github.com>
Date: Fri, 27 May 2022 19:05:35 +0200
Subject: [PATCH 20/87] Add new endpoints
---
category.go | 43 +++++++
crunchyroll.go | 300 +++++++++++++++++++++++++++++++++++++++++++++++++
news.go | 19 ++++
simulcast.go | 13 +++
utils.go | 48 ++++++++
5 files changed, 423 insertions(+)
create mode 100644 category.go
create mode 100644 news.go
create mode 100644 simulcast.go
diff --git a/category.go b/category.go
new file mode 100644
index 0000000..58855fc
--- /dev/null
+++ b/category.go
@@ -0,0 +1,43 @@
+package crunchyroll
+
+// Category contains all information about a category.
+type Category struct {
+ Category string `json:"tenant_category"`
+
+ SubCategories []struct {
+ Category string `json:"tenant_category"`
+ ParentCategory string `json:"parent_category"`
+
+ Localization struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Locale LOCALE `json:"locale"`
+ } `json:"localization"`
+
+ Slug string `json:"slug"`
+ } `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"`
+ } `json:"images"`
+
+ Localization struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Locale LOCALE `json:"locale"`
+ } `json:"localization"`
+
+ Slug string `json:"slug"`
+}
diff --git a/crunchyroll.go b/crunchyroll.go
index dcfc375..a81f373 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -30,6 +30,23 @@ const (
AR = "ar-SA"
)
+// SortOrder represents a sort order.
+type SortOrder string
+
+const (
+ POPULARITY SortOrder = "popularity"
+ NEWLYADDED = "newly_added"
+ ALPHABETICAL = "alphabetical"
+)
+
+// MediaType represents a media type.
+type MediaType string
+
+const (
+ SERIES MediaType = "series"
+ MOVIELISTING = "movie_listing"
+)
+
type Crunchyroll struct {
// Client is the http.Client to perform all requests over.
Client *http.Client
@@ -60,6 +77,30 @@ type Crunchyroll struct {
cache bool
}
+// BrowseOptions represents options for browsing the crunchyroll catalog.
+type BrowseOptions struct {
+ // Categories specifies the categories of the results.
+ Categories []string `param:"categories"`
+
+ // IsDubbed specifies whether the results should be dubbed.
+ IsDubbed bool `param:"is_dubbed"`
+
+ // IsSubbed specifies whether the results should be subbed.
+ IsSubbed bool `param:"is_subbed"`
+
+ // SimulcastID specifies a particular simulcast season in which the results have been aired.
+ SimulcastID string `param:"season_tag"`
+
+ // SortBy specifies how the results should be sorted.
+ SortBy SortOrder `param:"sort_by"`
+
+ // Start specifies the index from which the results should be returned.
+ Start uint `param:"start"`
+
+ // Type specifies the media type of the results.
+ Type MediaType `param:"type"`
+}
+
// LoginWithCredentials logs in via crunchyroll username or email and password.
func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
sessionIDEndpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?version=1.0&access_token=%s&device_type=%s&device_id=%s",
@@ -446,3 +487,262 @@ func ParseBetaEpisodeURL(url string) (episodeId string, ok bool) {
}
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)
+ 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)
+ 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)
+ 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 []*TopNews, l []*LatestNews, 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)
+ 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 := &TopNews{}
+ 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 := &LatestNews{}
+ 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 your 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)
+ 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 next episodes that you can continue watching based on your 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)
+ 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 to the one specified by id within the given limits.
+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)
+ 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
+}
diff --git a/news.go b/news.go
new file mode 100644
index 0000000..3985cb4
--- /dev/null
+++ b/news.go
@@ -0,0 +1,19 @@
+package crunchyroll
+
+// News contains all information about a news.
+type News struct {
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Image string `json:"image"`
+ Creator string `json:"creator"`
+ PublishDate string `json:"publish_date"`
+ Description string `json:"description"`
+}
+
+type TopNews struct {
+ News
+}
+
+type LatestNews struct {
+ News
+}
diff --git a/simulcast.go b/simulcast.go
new file mode 100644
index 0000000..d02f348
--- /dev/null
+++ b/simulcast.go
@@ -0,0 +1,13 @@
+package crunchyroll
+
+// Simulcast contains all information about a simulcast season.
+type Simulcast struct {
+ ID string `json:"id"`
+
+ Localization struct {
+ Title string `json:"title"`
+
+ // appears to be always an empty string.
+ Description string `json:"description"`
+ } `json:"localization"`
+}
diff --git a/utils.go b/utils.go
index a3d4191..672772f 100644
--- a/utils.go
+++ b/utils.go
@@ -2,6 +2,10 @@ package crunchyroll
import (
"encoding/json"
+ "fmt"
+ "net/url"
+ "reflect"
+ "strings"
)
func decodeMapToStruct(m interface{}, s interface{}) error {
@@ -23,3 +27,47 @@ func regexGroups(parsed [][]string, subexpNames ...string) map[string]string {
}
return groups
}
+
+func encodeStructToQueryValues(s interface{}) (string, error) {
+ values := make(url.Values)
+ v := reflect.ValueOf(s)
+
+ for i := 0; i < v.Type().NumField(); i++ {
+
+ // don't include parameters with default or without values in the query to avoid corruption of the API response.
+ if isEmptyValue(v.Field(i)) {
+ continue
+ }
+
+ key := v.Type().Field(i).Tag.Get("param")
+ var val string
+
+ if v.Field(i).Kind() == reflect.Slice {
+ var items []string
+
+ for _, i := range v.Field(i).Interface().([]string) {
+ items = append(items, i)
+ }
+
+ val = strings.Join(items, ",")
+ } else {
+ val = fmt.Sprint(v.Field(i).Interface())
+ }
+
+ values.Add(key, val)
+ }
+
+ return values.Encode(), nil
+}
+
+func isEmptyValue(v reflect.Value) bool {
+ switch v.Kind() {
+ case reflect.Slice, reflect.String:
+ return v.Len() == 0
+ case reflect.Bool:
+ return !v.Bool()
+ case reflect.Uint:
+ return v.Uint() == 0
+ }
+ return false
+}
From cf3559698582ee2589570fc59f4b67797b7c9554 Mon Sep 17 00:00:00 2001
From: IchBinLeoon <50367411+IchBinLeoon@users.noreply.github.com>
Date: Sat, 28 May 2022 19:55:56 +0200
Subject: [PATCH 21/87] Add watch history
---
crunchyroll.go | 48 +++++++++++++++++++++++++++++++++++++++++++-----
episode.go | 12 ++++++++++++
news.go | 8 --------
3 files changed, 55 insertions(+), 13 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index a81f373..aefce1a 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -591,7 +591,7 @@ func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) {
}
// 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 []*TopNews, l []*LatestNews, err error) {
+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)
@@ -607,7 +607,7 @@ func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*TopNews, l []*
topNews := jsonBody["top_news"].(map[string]interface{})
for _, item := range topNews["items"].([]interface{}) {
- topNews := &TopNews{}
+ topNews := &News{}
if err := decodeMapToStruct(item, topNews); err != nil {
return nil, nil, err
}
@@ -617,7 +617,7 @@ func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*TopNews, l []*
latestNews := jsonBody["latest_news"].(map[string]interface{})
for _, item := range latestNews["items"].([]interface{}) {
- latestNews := &LatestNews{}
+ latestNews := &News{}
if err := decodeMapToStruct(item, latestNews); err != nil {
return nil, nil, err
}
@@ -672,7 +672,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err
return s, m, nil
}
-// UpNext returns the next episodes that you can continue watching based on your account within the given limit.
+// UpNext returns the episodes that are up next based on your 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)
@@ -703,7 +703,7 @@ func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
return e, nil
}
-// SimilarTo returns similar series and movies to the one specified by id within the given limits.
+// SimilarTo returns similar series and movies according to crunchyroll to the one specified by id within the given limits.
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)
@@ -746,3 +746,41 @@ func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie,
return s, m, nil
}
+
+// WatchHistory returns the history of watched episodes based on your 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)
+ 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/episode.go b/episode.go
index a25844c..07f6705 100644
--- a/episode.go
+++ b/episode.go
@@ -75,6 +75,18 @@ type Episode struct {
StreamID string
}
+// HistoryEpisode contains additional information about an episode if the account has watched or started to watch the episode.
+type HistoryEpisode struct {
+ *Episode
+
+ ID string `json:"id"`
+ DatePlayed string `json:"date_played"`
+ ParentID string `json:"parent_id"`
+ ParentType MediaType `json:"parent_type"`
+ Playhead uint `json:"playhead"`
+ FullyWatched bool `json:"fully_watched"`
+}
+
// EpisodeFromID returns an episode by its api id.
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
diff --git a/news.go b/news.go
index 3985cb4..d90dd65 100644
--- a/news.go
+++ b/news.go
@@ -9,11 +9,3 @@ type News struct {
PublishDate string `json:"publish_date"`
Description string `json:"description"`
}
-
-type TopNews struct {
- News
-}
-
-type LatestNews struct {
- News
-}
From 7897da3baf200a77f9b9f876e7a7da9981d77e05 Mon Sep 17 00:00:00 2001
From: IchBinLeoon <50367411+IchBinLeoon@users.noreply.github.com>
Date: Sun, 29 May 2022 00:21:17 +0200
Subject: [PATCH 22/87] Add account and update comments
---
account.go | 27 ++++++++++++++++++
crunchyroll.go | 74 ++++++++++++++++++++++++++++++++++++--------------
2 files changed, 80 insertions(+), 21 deletions(-)
create mode 100644 account.go
diff --git a/account.go b/account.go
new file mode 100644
index 0000000..f6f87f1
--- /dev/null
+++ b/account.go
@@ -0,0 +1,27 @@
+package crunchyroll
+
+// Account contains information about a crunchyroll account.
+type Account struct {
+ AccountID string `json:"account_id"`
+ ExternalID string `json:"external_id"`
+ EmailVerified bool `json:"email_verified"`
+ Created string `json:"created"`
+
+ Avatar string `json:"avatar"`
+ CrBetaOptIn bool `json:"cr_beta_opt_in"`
+ Email string `json:"email"`
+ MatureContentFlagManga string `json:"mature_content_flag_manga"`
+ MaturityRating string `json:"maturity_rating"`
+ OptOutAndroidInAppMarketing bool `json:"opt_out_android_in_app_marketing"`
+ OptOutFreeTrials bool `json:"opt_out_free_trials"`
+ OptOutNewMediaQueueUpdates bool `json:"opt_out_new_media_queue_updates"`
+ OptOutNewsletters bool `json:"opt_out_newsletters"`
+ OptOutPmUpdates bool `json:"opt_out_pm_updates"`
+ OptOutPromotionalUpdates bool `json:"opt_out_promotional_updates"`
+ OptOutQueueUpdates bool `json:"opt_out_queue_updates"`
+ OptOutStoreDeals bool `json:"opt_out_store_deals"`
+ PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"`
+ PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"`
+ QaUser bool `json:"qa_user"`
+ Username string `json:"username"`
+}
diff --git a/crunchyroll.go b/crunchyroll.go
index aefce1a..4e5a436 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -30,15 +30,6 @@ const (
AR = "ar-SA"
)
-// SortOrder represents a sort order.
-type SortOrder string
-
-const (
- POPULARITY SortOrder = "popularity"
- NEWLYADDED = "newly_added"
- ALPHABETICAL = "alphabetical"
-)
-
// MediaType represents a media type.
type MediaType string
@@ -47,6 +38,15 @@ const (
MOVIELISTING = "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
@@ -79,25 +79,25 @@ type Crunchyroll struct {
// BrowseOptions represents options for browsing the crunchyroll catalog.
type BrowseOptions struct {
- // Categories specifies the categories of the results.
+ // Categories specifies the categories of the entries.
Categories []string `param:"categories"`
- // IsDubbed specifies whether the results should be dubbed.
+ // IsDubbed specifies whether the entries should be dubbed.
IsDubbed bool `param:"is_dubbed"`
- // IsSubbed specifies whether the results should be subbed.
+ // IsSubbed specifies whether the entries should be subbed.
IsSubbed bool `param:"is_subbed"`
- // SimulcastID specifies a particular simulcast season in which the results have been aired.
- SimulcastID string `param:"season_tag"`
+ // Simulcast specifies a particular simulcast season by id in which the entries have been aired.
+ Simulcast string `param:"season_tag"`
- // SortBy specifies how the results should be sorted.
- SortBy SortOrder `param:"sort_by"`
+ // Sort specifies how the entries should be sorted.
+ Sort SortType `param:"sort_by"`
- // Start specifies the index from which the results should be returned.
+ // Start specifies the index from which the entries should be returned.
Start uint `param:"start"`
- // Type specifies the media type of the results.
+ // Type specifies the media type of the entries.
Type MediaType `param:"type"`
}
@@ -672,7 +672,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err
return s, m, nil
}
-// UpNext returns the episodes that are up next based on your account within the given limit.
+// 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)
@@ -703,7 +703,7 @@ func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
return e, nil
}
-// SimilarTo returns similar series and movies according to crunchyroll to the one specified by id within the given limits.
+// 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)
@@ -747,7 +747,7 @@ func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie,
return s, m, nil
}
-// WatchHistory returns the history of watched episodes based on your account from the given page with the given size.
+// 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)
@@ -784,3 +784,35 @@ func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, e
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")
+ 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 'me' response: %w", err)
+ }
+
+ resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile")
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
+ }
+
+ account := &Account{}
+
+ if err := decodeMapToStruct(jsonBody, account); err != nil {
+ return nil, err
+ }
+
+ return account, nil
+}
From acc6c63ebd3f65e2e325018f20217e4433e7982b Mon Sep 17 00:00:00 2001
From: IchBinLeoon <50367411+IchBinLeoon@users.noreply.github.com>
Date: Sun, 29 May 2022 01:42:24 +0200
Subject: [PATCH 23/87] Fix comment
---
crunchyroll.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 4e5a436..5ee3a97 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -628,7 +628,7 @@ func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*New
return t, l, nil
}
-// Recommendations returns series and movie recommendations from crunchyroll based on your account within the given limit.
+// 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)
From b53256ca3fde95e834c4019b37eb230f7881713e Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Sun, 29 May 2022 10:10:48 +0200
Subject: [PATCH 24/87] Use Account struct directly instead of maps
---
crunchyroll.go | 13 ++++---------
1 file changed, 4 insertions(+), 9 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 5ee3a97..d20dd20 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -793,8 +793,9 @@ func (c *Crunchyroll) Account() (*Account, error) {
}
defer resp.Body.Close()
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ account := &Account{}
+
+ if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
}
@@ -804,15 +805,9 @@ func (c *Crunchyroll) Account() (*Account, error) {
}
defer resp.Body.Close()
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
}
- account := &Account{}
-
- if err := decodeMapToStruct(jsonBody, account); err != nil {
- return nil, err
- }
-
return account, nil
}
From 29343d1c6f8326332f4bba57c49de543fd63c2ae Mon Sep 17 00:00:00 2001
From: IchBinLeoon <50367411+IchBinLeoon@users.noreply.github.com>
Date: Sun, 29 May 2022 15:08:38 +0200
Subject: [PATCH 25/87] Add requested changes
---
account.go | 10 ++++++----
episode.go | 3 +--
utils.go | 27 +++++++++++++--------------
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/account.go b/account.go
index f6f87f1..d22eba5 100644
--- a/account.go
+++ b/account.go
@@ -1,11 +1,13 @@
package crunchyroll
+import "time"
+
// Account contains information about a crunchyroll account.
type Account struct {
- AccountID string `json:"account_id"`
- ExternalID string `json:"external_id"`
- EmailVerified bool `json:"email_verified"`
- Created string `json:"created"`
+ AccountID string `json:"account_id"`
+ ExternalID string `json:"external_id"`
+ EmailVerified bool `json:"email_verified"`
+ Created time.Time `json:"created"`
Avatar string `json:"avatar"`
CrBetaOptIn bool `json:"cr_beta_opt_in"`
diff --git a/episode.go b/episode.go
index 07f6705..de38c65 100644
--- a/episode.go
+++ b/episode.go
@@ -79,8 +79,7 @@ type Episode struct {
type HistoryEpisode struct {
*Episode
- ID string `json:"id"`
- DatePlayed string `json:"date_played"`
+ DatePlayed time.Time `json:"date_played"`
ParentID string `json:"parent_id"`
ParentType MediaType `json:"parent_type"`
Playhead uint `json:"playhead"`
diff --git a/utils.go b/utils.go
index 672772f..d32d39b 100644
--- a/utils.go
+++ b/utils.go
@@ -35,8 +35,19 @@ func encodeStructToQueryValues(s interface{}) (string, error) {
for i := 0; i < v.Type().NumField(); i++ {
// don't include parameters with default or without values in the query to avoid corruption of the API response.
- if isEmptyValue(v.Field(i)) {
- continue
+ switch v.Field(i).Kind() {
+ case reflect.Slice, reflect.String:
+ if v.Field(i).Len() == 0 {
+ continue
+ }
+ case reflect.Bool:
+ if !v.Field(i).Bool() {
+ continue
+ }
+ case reflect.Uint:
+ if v.Field(i).Uint() == 0 {
+ continue
+ }
}
key := v.Type().Field(i).Tag.Get("param")
@@ -59,15 +70,3 @@ func encodeStructToQueryValues(s interface{}) (string, error) {
return values.Encode(), nil
}
-
-func isEmptyValue(v reflect.Value) bool {
- switch v.Kind() {
- case reflect.Slice, reflect.String:
- return v.Len() == 0
- case reflect.Bool:
- return !v.Bool()
- case reflect.Uint:
- return v.Uint() == 0
- }
- return false
-}
From 6581a5bd0f14f0601c2e4b84478bad84df74998d Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 29 May 2022 16:10:37 +0200
Subject: [PATCH 26/87] Fix typo
---
cmd/crunchyroll-go/cmd/utils.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index aa98399..0a89921 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -148,9 +148,9 @@ func loadCrunchy() {
os.Exit(1)
}
if crunchy, err = crunchyroll.LoginWithEtpRt(url.QueryEscape(string(body)), systemLocale(true), client); err != nil {
- out.Debug("Failed to login with temp etp rt: %w", err)
+ out.Debug("Failed to login with temp etp rt cookie: %w", err)
} else {
- out.Debug("Logged in with etp rt %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
+ out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
out.StopProgress("Logged in")
return
@@ -172,7 +172,7 @@ func loadCrunchy() {
out.StopProgress(err.Error())
os.Exit(1)
}
- out.Debug("Logged in with etp rt %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
+ out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
} else {
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
From 2471042d02b743e9925493e2b69e78c237a70ecb Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 30 May 2022 08:56:30 +0200
Subject: [PATCH 27/87] Change error formatter for non Errorf
---
cmd/crunchyroll-go/cmd/utils.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 0a89921..5c4a36a 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -148,7 +148,7 @@ func loadCrunchy() {
os.Exit(1)
}
if crunchy, err = crunchyroll.LoginWithEtpRt(url.QueryEscape(string(body)), systemLocale(true), client); err != nil {
- out.Debug("Failed to login with temp etp rt cookie: %w", err)
+ out.Debug("Failed to login with temp etp rt cookie: %v", err)
} else {
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
From 1c37c3e699e033f2eedf41ca8326c125ef826319 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 30 May 2022 12:08:52 +0200
Subject: [PATCH 28/87] Add method parameter to internal request function
---
crunchyroll.go | 12 ++++++------
episode.go | 3 ++-
movie_listing.go | 5 +++--
season.go | 3 ++-
stream.go | 3 ++-
video.go | 9 +++++----
6 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 06a28ec..bdffa27 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -185,7 +185,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
var jsonBody map[string]any
endpoint := "https://beta-api.crunchyroll.com/index/v2"
- resp, err := crunchy.request(endpoint)
+ resp, err := crunchy.request(endpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -205,7 +205,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me"
- resp, err = crunchy.request(endpoint)
+ resp, err = crunchy.request(endpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -214,7 +214,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
crunchy.Config.ExternalID = jsonBody["external_id"].(string)
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/profile"
- resp, err = crunchy.request(endpoint)
+ resp, err = crunchy.request(endpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -264,8 +264,8 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
}
// request is a base function which handles api requests.
-func (c *Crunchyroll) request(endpoint string) (*http.Response, error) {
- req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
+ req, err := http.NewRequest(method, endpoint, nil)
if err != nil {
return nil, err
}
@@ -292,7 +292,7 @@ func (c *Crunchyroll) SetCaching(caching bool) {
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)
+ resp, err := c.request(searchEndpoint, http.MethodGet)
if err != nil {
return nil, nil, err
}
diff --git a/episode.go b/episode.go
index a25844c..e9dd16a 100644
--- a/episode.go
+++ b/episode.go
@@ -3,6 +3,7 @@ package crunchyroll
import (
"encoding/json"
"fmt"
+ "net/http"
"regexp"
"strconv"
"strings"
@@ -85,7 +86,7 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
crunchy.Locale,
crunchy.Config.Signature,
crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
+ crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
diff --git a/movie_listing.go b/movie_listing.go
index 63d7fab..110f67a 100644
--- a/movie_listing.go
+++ b/movie_listing.go
@@ -3,6 +3,7 @@ package crunchyroll
import (
"encoding/json"
"fmt"
+ "net/http"
)
// MovieListing contains information about something which is called
@@ -48,7 +49,7 @@ func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error)
crunchy.Locale,
crunchy.Config.Signature,
crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
+ crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
@@ -77,7 +78,7 @@ func (ml *MovieListing) AudioLocale() (LOCALE, error) {
ml.crunchy.Locale,
ml.crunchy.Config.Signature,
ml.crunchy.Config.Policy,
- ml.crunchy.Config.KeyPairID))
+ ml.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return "", err
}
diff --git a/season.go b/season.go
index 825a816..f5513f5 100644
--- a/season.go
+++ b/season.go
@@ -3,6 +3,7 @@ package crunchyroll
import (
"encoding/json"
"fmt"
+ "net/http"
"regexp"
)
@@ -94,7 +95,7 @@ func (s *Season) Episodes() (episodes []*Episode, err error) {
s.crunchy.Locale,
s.crunchy.Config.Signature,
s.crunchy.Config.Policy,
- s.crunchy.Config.KeyPairID))
+ s.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
diff --git a/stream.go b/stream.go
index 7505a24..00060ef 100644
--- a/stream.go
+++ b/stream.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/grafov/m3u8"
+ "net/http"
"regexp"
)
@@ -72,7 +73,7 @@ func (s *Stream) Formats() ([]*Format, error) {
// fromVideoStreams returns all streams which are accessible via the endpoint.
func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream, err error) {
- resp, err := crunchy.request(endpoint)
+ resp, err := crunchy.request(endpoint, http.MethodGet)
if err != nil {
return nil, err
}
diff --git a/video.go b/video.go
index 00b7734..365f954 100644
--- a/video.go
+++ b/video.go
@@ -3,6 +3,7 @@ package crunchyroll
import (
"encoding/json"
"fmt"
+ "net/http"
)
type video struct {
@@ -77,7 +78,7 @@ func MovieFromID(crunchy *Crunchyroll, id string) (*Movie, error) {
crunchy.Locale,
crunchy.Config.Signature,
crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
+ crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
@@ -110,7 +111,7 @@ func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
m.crunchy.Locale,
m.crunchy.Config.Signature,
m.crunchy.Config.Policy,
- m.crunchy.Config.KeyPairID))
+ m.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
@@ -173,7 +174,7 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
crunchy.Locale,
crunchy.Config.Signature,
crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
+ crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
@@ -206,7 +207,7 @@ func (s *Series) Seasons() (seasons []*Season, err error) {
s.crunchy.Locale,
s.crunchy.Config.Signature,
s.crunchy.Config.Policy,
- s.crunchy.Config.KeyPairID))
+ s.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil {
return nil, err
}
From 3a7ec02598ea1f857b51a51307b5036a0742db0c Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Mon, 30 May 2022 12:24:54 +0200
Subject: [PATCH 29/87] Resolve merge conflicts
---
crunchyroll.go | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 268562d..e5c4a9d 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -169,15 +169,13 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*
return nil, fmt.Errorf("failed to start session: %s", resp.Status)
}
+ var jsonBody map[string]any
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{})
-
- crunchy.Config.CountryCode = data["country_code"].(string)
var etpRt string
for _, cookie := range resp.Cookies() {
@@ -243,6 +241,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
+
cms := jsonBody["cms"].(map[string]any)
crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
if strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") {
@@ -261,7 +260,6 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
crunchy.Config.Channel = "-"
}
- cms := jsonBody["cms"].(map[string]interface{})
crunchy.Config.Policy = cms["policy"].(string)
crunchy.Config.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
@@ -526,7 +524,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m
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)
+ resp, err := c.request(browseEndpoint, http.MethodGet)
if err != nil {
return nil, nil, err
}
@@ -570,7 +568,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m
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)
+ resp, err := c.request(tenantCategoriesEndpoin, http.MethodGet)
if err != nil {
return nil, err
}
@@ -623,7 +621,7 @@ func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) {
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)
+ resp, err := c.request(newsFeedEndpoint, http.MethodGet)
if err != nil {
return nil, nil, err
}
From 35b1cbbdb42328d848fbef5ff3d6f157f9923297 Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Mon, 30 May 2022 12:27:20 +0200
Subject: [PATCH 30/87] Fix typo
---
crunchyroll.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index e5c4a9d..5154c9c 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -568,7 +568,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m
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(tenantCategoriesEndpoin, http.MethodGet)
+ resp, err := c.request(tenantCategoriesEndpoint, http.MethodGet)
if err != nil {
return nil, err
}
From 38fe521d55a8377697c03e86490443688fb9295d Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 30 May 2022 12:32:13 +0200
Subject: [PATCH 31/87] Resolve more merge conflicts which GitHub didn't save
lul
---
crunchyroll.go | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 5154c9c..38b889b 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -241,7 +241,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
-
+
cms := jsonBody["cms"].(map[string]any)
crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
if strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") {
@@ -259,7 +259,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
crunchy.Config.Premium = false
crunchy.Config.Channel = "-"
}
-
+
crunchy.Config.Policy = cms["policy"].(string)
crunchy.Config.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
@@ -594,7 +594,7 @@ func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err
// 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)
+ resp, err := c.request(seasonListEndpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -659,7 +659,7 @@ func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*New
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)
+ resp, err := c.request(recommendationsEndpoint, http.MethodGet)
if err != nil {
return nil, nil, err
}
@@ -703,7 +703,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err
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)
+ resp, err := c.request(upNextAccountEndpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -734,7 +734,7 @@ func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
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)
+ resp, err := c.request(similarToEndpoint, http.MethodGet)
if err != nil {
return nil, nil, err
}
@@ -778,7 +778,7 @@ func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie,
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)
+ resp, err := c.request(watchHistoryEndpoint, http.MethodGet)
if err != nil {
return nil, err
}
@@ -814,7 +814,7 @@ func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, e
// 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")
+ resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
if err != nil {
return nil, err
}
@@ -826,7 +826,7 @@ func (c *Crunchyroll) Account() (*Account, error) {
return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
}
- resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile")
+ resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet)
if err != nil {
return nil, err
}
From b9ff56c111e3a5aa1cfa7f730d04a173e3524210 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 31 May 2022 17:01:03 +0200
Subject: [PATCH 32/87] Extend encrypt flag description
---
cmd/crunchyroll-go/cmd/login.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index 565dac0..1c47340 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -43,7 +43,7 @@ func init() {
loginCmd.Flags().BoolVar(&loginEncryptFlag,
"encrypt",
false,
- "Encrypt the given credentials (won't do anything if --session-id is given)")
+ "Encrypt the given credentials (won't do anything if --session-id is given or --persistent is not given)")
loginCmd.Flags().BoolVar(&loginSessionIDFlag,
"session-id",
From ce29e31164ba25396cbcbb3b619723e5ffa283d4 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 31 May 2022 17:04:39 +0200
Subject: [PATCH 33/87] Add encrypt flag notice when use login without it
---
cmd/crunchyroll-go/cmd/login.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index 1c47340..d31179e 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -124,7 +124,8 @@ func loginCredentials(user, password string) error {
return err
}
if !loginEncryptFlag {
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+
+ "To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
}
}
}
From c963af1f11c2585494372243d31a7c508c466247 Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Wed, 8 Jun 2022 14:10:46 +0200
Subject: [PATCH 34/87] Why github
---
cmd/crunchyroll-go/cmd/utils.go | 3 ---
1 file changed, 3 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index 7fe498f..dd1254c 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -170,7 +170,6 @@ func loadCrunchy() {
}
split := strings.SplitN(string(body), "\n", 2)
if len(split) == 1 || split[1] == "" {
-<<<<<<< v3/feature/encrypted-credentials
if strings.HasPrefix(split[0], "aes:") {
encrypted := body[4:]
@@ -214,7 +213,6 @@ func loadCrunchy() {
}
if len(split) == 2 {
-=======
split[0] = url.QueryEscape(split[0])
if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
@@ -222,7 +220,6 @@ func loadCrunchy() {
}
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
} else {
->>>>>>> next/v3
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
From 735136077eeb1cbd916d5c95627898b90525237e Mon Sep 17 00:00:00 2001
From: ByteDream <63594396+ByteDream@users.noreply.github.com>
Date: Wed, 8 Jun 2022 14:12:30 +0200
Subject: [PATCH 35/87] I removed it but ok github if you want i do it a second
time, no problem
---
cmd/crunchyroll-go/cmd/login.go | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index bfef794..0c00100 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -135,13 +135,10 @@ func loginCredentials(user, password string) error {
"To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
}
}
- }
-<<<<<<< v3/feature/encrypted-credentials
-=======
+
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
return err
}
->>>>>>> next/v3
if !loginPersistentFlag {
out.Info("Due to security reasons, you have to login again on the next reboot")
From 69d2e10362929c415ecc050ac73535eedc7defdf Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 8 Jun 2022 14:18:20 +0200
Subject: [PATCH 36/87] I fucking hate it
---
cmd/crunchyroll-go/cmd/login.go | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go
index 0c00100..d8cc5c3 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/cmd/crunchyroll-go/cmd/login.go
@@ -67,10 +67,6 @@ func loginCredentials(user, password string) error {
return err
}
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.SessionID), 0600); err != nil {
- return err
- }
-
if loginPersistentFlag {
if configDir, err := os.UserConfigDir(); err != nil {
return fmt.Errorf("could not save credentials persistent: %w", err)
@@ -135,7 +131,8 @@ func loginCredentials(user, password string) error {
"To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
}
}
-
+ }
+
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
return err
}
From dcdde6749e971aa0dbc1ee104d9fbf123883c217 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 9 Jun 2022 09:56:32 +0200
Subject: [PATCH 37/87] Add info command
---
cmd/crunchyroll-go/cmd/info.go | 40 ++++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 cmd/crunchyroll-go/cmd/info.go
diff --git a/cmd/crunchyroll-go/cmd/info.go b/cmd/crunchyroll-go/cmd/info.go
new file mode 100644
index 0000000..f5ed995
--- /dev/null
+++ b/cmd/crunchyroll-go/cmd/info.go
@@ -0,0 +1,40 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/spf13/cobra"
+)
+
+var infoCmd = &cobra.Command{
+ Use: "info",
+ Short: "Shows information about the logged in user",
+ Args: cobra.MinimumNArgs(0),
+
+ RunE: func(cmd *cobra.Command, args []string) error {
+ loadCrunchy()
+
+ return info()
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(infoCmd)
+}
+
+func info() error {
+ account, err := crunchy.Account()
+ if err != nil {
+ return err
+ }
+
+ fmt.Println("Username: ", account.Username)
+ fmt.Println("Email: ", account.Email)
+ fmt.Println("Premium: ", crunchy.Config.Premium)
+ fmt.Println("Interface language:", utils.LocaleLanguage(account.PreferredCommunicationLanguage))
+ fmt.Println("Subtitle language: ", utils.LocaleLanguage(account.PreferredContentSubtitleLanguage))
+ fmt.Println("Created: ", account.Created)
+ fmt.Println("Account ID: ", account.AccountID)
+
+ return nil
+}
From 31d3065e7bcc30ac6693254be4ae4a68570894ce Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 9 Jun 2022 10:31:16 +0200
Subject: [PATCH 38/87] Fix weird code which was probably caused by a wonderful
merge
---
cmd/crunchyroll-go/cmd/utils.go | 29 +++++++++++------------------
1 file changed, 11 insertions(+), 18 deletions(-)
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go
index dd1254c..12d3d4e 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/cmd/crunchyroll-go/cmd/utils.go
@@ -150,7 +150,7 @@ func loadCrunchy() {
out.StopProgress("Failed to read login information: %v", err)
os.Exit(1)
}
- if crunchy, err = crunchyroll.LoginWithEtpRt(url.QueryEscape(string(body)), systemLocale(true), client); err != nil {
+ if crunchy, err = crunchyroll.LoginWithEtpRt(string(body), systemLocale(true), client); err != nil {
out.Debug("Failed to login with temp etp rt cookie: %v", err)
} else {
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
@@ -202,34 +202,27 @@ func loadCrunchy() {
os.Exit(1)
}
split = strings.SplitN(string(b), "\n", 2)
- } else {
- split[0] = url.QueryEscape(split[0])
- if crunchy, err = crunchyroll.LoginWithSessionID(split[0], systemLocale(true), client); err != nil {
- out.StopProgress(err.Error())
- os.Exit(1)
- }
- out.Debug("Logged in with session id %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
}
}
if len(split) == 2 {
- split[0] = url.QueryEscape(split[0])
+ if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
+ out.StopProgress(err.Error())
+ os.Exit(1)
+ }
+ out.Debug("Logged in with credentials")
+ } else {
if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
}
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
- } else {
- if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
- out.StopProgress(err.Error())
- os.Exit(1)
- }
- out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", crunchy.EtpRt)
- // the etp rt is written to a temp file to reduce the amount of re-logging in.
- // it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
- os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
}
+ // the etp rt is written to a temp file to reduce the amount of re-logging in.
+ // it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
+ os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
+
out.StopProgress("Logged in")
return
}
From 137f3779ea2e2182ab1c15ed35c89fd931587732 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 10 Jun 2022 16:12:58 +0200
Subject: [PATCH 39/87] Fix merge issues again, I love this shit
---
crunchyroll.go | 41 +++++++++++++----------------------------
stream.go | 2 +-
2 files changed, 14 insertions(+), 29 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 73cba57..be5af03 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -110,6 +111,9 @@ 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.
@@ -133,16 +137,13 @@ func LoginWithCredentials(user string, password string, locale LOCALE, client *h
}
defer resp.Body.Close()
- if loginResp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to auth with credentials: %s", loginResp.Status)
- } else {
- var loginRespBody map[string]interface{}
- json.NewDecoder(loginResp.Body).Decode(&loginRespBody)
-
- if loginRespBody["error"].(bool) {
- return nil, fmt.Errorf("an unexpected login error occoured: %s", loginRespBody["message"])
- }
- }
+ 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() {
@@ -180,18 +181,16 @@ 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{})
- crunchy.Config.CountryCode = data["country_code"].(string)
user := data["user"]
if user == nil {
return nil, errors.New("invalid session id, user is not logged in")
}
- crunchy.Config.Premium = user.(map[string]interface{})["premium"] != ""
var etpRt string
for _, cookie := range resp.Cookies() {
@@ -260,21 +259,7 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
cms := jsonBody["cms"].(map[string]any)
crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
- if strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") {
- crunchy.Config.Premium = true
- crunchy.Config.Channel = "crunchyroll"
- } else {
- crunchy.Config.Premium = false
- crunchy.Config.Channel = "-"
- }
-
- if strings.Contains(cms["bucket"].(string), "crunchyroll") {
- crunchy.Config.Premium = true
- crunchy.Config.Channel = "crunchyroll"
- } else {
- crunchy.Config.Premium = false
- crunchy.Config.Channel = "-"
- }
+ 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
diff --git a/stream.go b/stream.go
index 042d5f7..e5ce54f 100644
--- a/stream.go
+++ b/stream.go
@@ -84,7 +84,7 @@ func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream,
if !crunchy.Config.Premium {
return nil, fmt.Errorf("no stream available, this might be the result of using a non-premium account")
} else {
- return nil, errors.New("no stream available")
+ return nil, fmt.Errorf("no stream available")
}
}
From c5f2b55f346d2e45a6cf09ffa8750c915a180970 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 00:22:38 +0200
Subject: [PATCH 40/87] Add watchlist endpoint, add new request method & change
SortType name and consts
---
crunchyroll.go | 93 +++++++++++++++++++++++++++++++++++++++++++----
episode.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++----
video.go | 21 +++++++++++
3 files changed, 199 insertions(+), 12 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 38b889b..7618fdf 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -38,13 +38,28 @@ const (
MOVIELISTING = "movie_listing"
)
-// SortType represents a sort type.
-type SortType string
+// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after.
+type BrowseSortType string
const (
- POPULARITY SortType = "popularity"
- NEWLYADDED = "newly_added"
- ALPHABETICAL = "alphabetical"
+ BROWSESORTPOPULARITY BrowseSortType = "popularity"
+ BROWSESORTNEWLYADDED = "newly_added"
+ BROWSESORTALPHABETICAL = "alphabetical"
+)
+
+// WatchlistLanguageType represents a filter type to filter Crunchyroll.WatchList entries after.
+type WatchlistLanguageType int
+
+const (
+ WATCHLISTLANGUAGESUBBED WatchlistLanguageType = iota + 1
+ WATCHLISTLANGUAGEDUBBED
+)
+
+type WatchlistContentType string
+
+const (
+ WATCHLISTCONTENTSERIES WatchlistContentType = "series"
+ WATCHLISTCONTENTMOVIES = "movie_listing"
)
type Crunchyroll struct {
@@ -95,7 +110,7 @@ type BrowseOptions struct {
Simulcast string `param:"season_tag"`
// Sort specifies how the entries should be sorted.
- Sort SortType `param:"sort_by"`
+ Sort BrowseSortType `param:"sort_by"`
// Start specifies the index from which the entries should be returned.
Start uint `param:"start"`
@@ -329,6 +344,10 @@ func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, e
if err != nil {
return nil, err
}
+ return c.requestFull(req)
+}
+
+func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) {
req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
return request(req, c.Client)
@@ -812,6 +831,68 @@ func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, e
return e, nil
}
+type WatchlistOptions struct {
+ // OrderAsc specified whether the results should be order ascending or descending.
+ OrderAsc bool
+
+ // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned.
+ OnlyFavorites bool
+
+ // LanguageType specifies whether returning episodes should be only subbed or dubbed.
+ LanguageType WatchlistLanguageType
+
+ // ContentType specified whether returning videos should only be series episodes or movies.
+ // But tbh all movies I've searched on crunchy were flagged as series too, so this
+ // parameter is kinda useless.
+ ContentType WatchlistContentType
+}
+
+func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) {
+ values := url.Values{}
+ if options.OrderAsc {
+ values.Set("order", "asc")
+ } else {
+ values.Set("order", "desc")
+ }
+ if options.OnlyFavorites {
+ values.Set("only_favorites", "true")
+ }
+ switch options.LanguageType {
+ case WATCHLISTLANGUAGESUBBED:
+ values.Set("is_subbed", "true")
+ case WATCHLISTLANGUAGEDUBBED:
+ values.Set("is_dubbed", "true")
+ }
+ values.Set("n", strconv.Itoa(int(limit)))
+ values.Set("locale", string(c.Locale))
+
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode())
+ resp, err := c.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ var watchlistEntries []*WatchlistEntry
+ if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil {
+ return nil, err
+ }
+
+ for _, entry := range watchlistEntries {
+ switch entry.Panel.Type {
+ case WATCHLISTENTRYEPISODE:
+ entry.Panel.EpisodeMetadata.crunchy = c
+ case WATCHLISTENTRYSERIES:
+ entry.Panel.SeriesMetadata.crunchy = c
+ }
+ }
+
+ return watchlistEntries, nil
+}
+
// Account returns information about the currently logged in crunchyroll account.
func (c *Crunchyroll) Account() (*Account, error) {
resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
diff --git a/episode.go b/episode.go
index 0405e0e..e99bd12 100644
--- a/episode.go
+++ b/episode.go
@@ -1,6 +1,7 @@
package crunchyroll
import (
+ "bytes"
"encoding/json"
"fmt"
"net/http"
@@ -39,11 +40,14 @@ type Episode struct {
NextEpisodeID string `json:"next_episode_id"`
NextEpisodeTitle string `json:"next_episode_title"`
- HDFlag bool `json:"hd_flag"`
- IsMature bool `json:"is_mature"`
- MatureBlocked bool `json:"mature_blocked"`
+ HDFlag bool `json:"hd_flag"`
+ MaturityRatings []string `json:"maturity_ratings"`
+ IsMature bool `json:"is_mature"`
+ MatureBlocked bool `json:"mature_blocked"`
- EpisodeAirDate time.Time `json:"episode_air_date"`
+ EpisodeAirDate time.Time `json:"episode_air_date"`
+ FreeAvailableDate time.Time `json:"free_available_date"`
+ PremiumAvailableDate time.Time `json:"premium_available_date"`
IsSubbed bool `json:"is_subbed"`
IsDubbed bool `json:"is_dubbed"`
@@ -52,8 +56,9 @@ type Episode struct {
SeoDescription string `json:"seo_description"`
SeasonTags []string `json:"season_tags"`
- AvailableOffline bool `json:"available_offline"`
- Slug string `json:"slug"`
+ AvailableOffline bool `json:"available_offline"`
+ MediaType MediaType `json:"media_type"`
+ Slug string `json:"slug"`
Images struct {
Thumbnail [][]struct {
@@ -87,6 +92,62 @@ type HistoryEpisode struct {
FullyWatched bool `json:"fully_watched"`
}
+// WatchlistEntryType specifies which type a watchlist entry has.
+type WatchlistEntryType string
+
+const (
+ WATCHLISTENTRYEPISODE = "episode"
+ WATCHLISTENTRYSERIES = "series"
+)
+
+// WatchlistEntry contains information about an entry on the watchlist.
+type WatchlistEntry struct {
+ Panel struct {
+ Title string `json:"title"`
+ PromoTitle string `json:"promo_title"`
+ Slug string `json:"slug"`
+ Playback string `json:"playback"`
+ PromoDescription string `json:"promo_description"`
+ Images struct {
+ Thumbnail [][]struct {
+ Height int `json:"height"`
+ Source string `json:"source"`
+ Type string `json:"type"`
+ Width int `json:"width"`
+ } `json:"thumbnail"`
+ PosterTall [][]struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Type string `json:"type"`
+ Source string `json:"source"`
+ } `json:"poster_tall"`
+ PosterWide [][]struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Type string `json:"type"`
+ Source string `json:"source"`
+ } `json:"poster_wide"`
+ } `json:"images"`
+ ID string `json:"id"`
+ Description string `json:"description"`
+ ChannelID string `json:"channel_id"`
+ Type WatchlistEntryType `json:"type"`
+ ExternalID string `json:"external_id"`
+ SlugTitle string `json:"slug_title"`
+ // not null if Type is WATCHLISTENTRYEPISODE
+ EpisodeMetadata *Episode `json:"episode_metadata"`
+ // not null if Type is WATCHLISTENTRYSERIES
+ SeriesMetadata *Series `json:"series_metadata"`
+ }
+
+ New bool `json:"new"`
+ NewContent bool `json:"new_content"`
+ IsFavorite bool `json:"is_favorite"`
+ NeverWatched bool `json:"never_watched"`
+ CompleteStatus bool `json:"complete_status"`
+ Playahead uint `json:"playahead"`
+}
+
// EpisodeFromID returns an episode by its api id.
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
@@ -122,6 +183,30 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
return episode, nil
}
+// AddToWatchlist adds the current episode to the watchlist.
+// There is currently a bug, or as I like to say in context of the crunchyroll api, feature,
+// that only series and not individual episode can be added to the watchlist. Even though
+// I somehow got an episode to my watchlist on the crunchyroll website, it never worked with the
+// api here. So this function actually adds the whole series to the watchlist.
+func (e *Episode) AddToWatchlist() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", e.crunchy.Config.AccountID, e.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"content_id": e.SeriesID})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = e.crunchy.requestFull(req)
+ return err
+}
+
+// RemoveFromWatchlist removes the current episode from the watchlist.
+func (e *Episode) RemoveFromWatchlist() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", e.crunchy.Config.AccountID, e.SeriesID, e.crunchy.Locale)
+ _, err := e.crunchy.request(endpoint, http.MethodDelete)
+ return err
+}
+
// AudioLocale returns the audio locale of the episode.
// Every episode in a season (should) have the same audio locale,
// so if you want to get the audio locale of a season, just call
diff --git a/video.go b/video.go
index 365f954..42efacd 100644
--- a/video.go
+++ b/video.go
@@ -1,6 +1,7 @@
package crunchyroll
import (
+ "bytes"
"encoding/json"
"fmt"
"net/http"
@@ -193,6 +194,26 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
return series, nil
}
+// AddToWatchlist adds the current episode to the watchlist.
+func (s *Series) AddToWatchlist() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", s.crunchy.Config.AccountID, s.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"content_id": s.ID})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = s.crunchy.requestFull(req)
+ return err
+}
+
+// RemoveFromWatchlist removes the current episode from the watchlist.
+func (s *Series) RemoveFromWatchlist() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", s.crunchy.Config.AccountID, s.ID, s.crunchy.Locale)
+ _, err := s.crunchy.request(endpoint, http.MethodDelete)
+ return err
+}
+
// Seasons returns all seasons of a series.
func (s *Series) Seasons() (seasons []*Season, err error) {
if s.children != nil {
From 5709012dfe5f625426ba28b99b52720a6ff32b8f Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 00:36:27 +0200
Subject: [PATCH 41/87] Add custom error for internal request
---
crunchyroll.go | 10 +++++-----
episode.go | 9 +++++----
error.go | 17 +++++++++++++++++
video.go | 2 ++
4 files changed, 29 insertions(+), 9 deletions(-)
create mode 100644 error.go
diff --git a/crunchyroll.go b/crunchyroll.go
index 7618fdf..4c29ab4 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -314,25 +314,25 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
var errMap map[string]any
if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil {
- return nil, fmt.Errorf("invalid json response: %w", err)
+ return nil, &RequestError{Response: resp, Message: fmt.Sprintf("invalid json response: %w", err)}
}
if val, ok := errMap["error"]; ok {
if errorAsString, ok := val.(string); ok {
if code, ok := errMap["code"].(string); ok {
- return nil, fmt.Errorf("error for endpoint %s (%d): %s - %s", req.URL.String(), resp.StatusCode, errorAsString, code)
+ return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errorAsString, code)}
}
- return nil, fmt.Errorf("error for endpoint %s (%d): %s", req.URL.String(), resp.StatusCode, errorAsString)
+ return nil, &RequestError{Response: resp, Message: errorAsString}
} else if errorAsBool, ok := val.(bool); ok && errorAsBool {
if msg, ok := errMap["message"].(string); ok {
- return nil, fmt.Errorf("error for endpoint %s (%d): %s", req.URL.String(), resp.StatusCode, msg)
+ return nil, &RequestError{Response: resp, Message: msg}
}
}
}
}
if resp.StatusCode >= 400 {
- return nil, fmt.Errorf("error for endpoint %s: %s", req.URL.String(), resp.Status)
+ return nil, &RequestError{Response: resp, Message: resp.Status}
}
}
return resp, err
diff --git a/episode.go b/episode.go
index e99bd12..d0e9b14 100644
--- a/episode.go
+++ b/episode.go
@@ -184,10 +184,10 @@ func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
}
// AddToWatchlist adds the current episode to the watchlist.
-// There is currently a bug, or as I like to say in context of the crunchyroll api, feature,
-// that only series and not individual episode can be added to the watchlist. Even though
-// I somehow got an episode to my watchlist on the crunchyroll website, it never worked with the
-// api here. So this function actually adds the whole series to the watchlist.
+// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before.
+// There is currently a bug, or as I like to say in context of the crunchyroll api, feature, that only series and not
+// individual episode can be added to the watchlist. Even though I somehow got an episode to my watchlist on the
+// crunchyroll website, it never worked with the api here. So this function actually adds the whole series to the watchlist.
func (e *Episode) AddToWatchlist() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", e.crunchy.Config.AccountID, e.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"content_id": e.SeriesID})
@@ -201,6 +201,7 @@ func (e *Episode) AddToWatchlist() error {
}
// RemoveFromWatchlist removes the current episode from the watchlist.
+// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before.
func (e *Episode) RemoveFromWatchlist() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", e.crunchy.Config.AccountID, e.SeriesID, e.crunchy.Locale)
_, err := e.crunchy.request(endpoint, http.MethodDelete)
diff --git a/error.go b/error.go
new file mode 100644
index 0000000..b4f7329
--- /dev/null
+++ b/error.go
@@ -0,0 +1,17 @@
+package crunchyroll
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type RequestError struct {
+ error
+
+ Response *http.Response
+ Message string
+}
+
+func (re *RequestError) String() string {
+ return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message)
+}
diff --git a/video.go b/video.go
index 42efacd..3e7d7cf 100644
--- a/video.go
+++ b/video.go
@@ -195,6 +195,7 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
}
// AddToWatchlist adds the current episode to the watchlist.
+// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before.
func (s *Series) AddToWatchlist() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", s.crunchy.Config.AccountID, s.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"content_id": s.ID})
@@ -208,6 +209,7 @@ func (s *Series) AddToWatchlist() error {
}
// RemoveFromWatchlist removes the current episode from the watchlist.
+// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before.
func (s *Series) RemoveFromWatchlist() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", s.crunchy.Config.AccountID, s.ID, s.crunchy.Locale)
_, err := s.crunchy.request(endpoint, http.MethodDelete)
From f9792aa84781b893c9b589202caacd7325cdb347 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 00:54:30 +0200
Subject: [PATCH 42/87] Add extra file for common in different places used
elements
---
common.go | 39 +++++++++++++++++++++++++++++++++++++++
episode.go | 38 +-------------------------------------
2 files changed, 40 insertions(+), 37 deletions(-)
create mode 100644 common.go
diff --git a/common.go b/common.go
new file mode 100644
index 0000000..91e1312
--- /dev/null
+++ b/common.go
@@ -0,0 +1,39 @@
+package crunchyroll
+
+type Panel struct {
+ Title string `json:"title"`
+ PromoTitle string `json:"promo_title"`
+ Slug string `json:"slug"`
+ Playback string `json:"playback"`
+ PromoDescription string `json:"promo_description"`
+ Images struct {
+ Thumbnail [][]struct {
+ Height int `json:"height"`
+ Source string `json:"source"`
+ Type string `json:"type"`
+ Width int `json:"width"`
+ } `json:"thumbnail"`
+ PosterTall [][]struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Type string `json:"type"`
+ Source string `json:"source"`
+ } `json:"poster_tall"`
+ PosterWide [][]struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Type string `json:"type"`
+ Source string `json:"source"`
+ } `json:"poster_wide"`
+ } `json:"images"`
+ ID string `json:"id"`
+ Description string `json:"description"`
+ ChannelID string `json:"channel_id"`
+ Type WatchlistEntryType `json:"type"`
+ ExternalID string `json:"external_id"`
+ SlugTitle string `json:"slug_title"`
+ // not null if Type is WATCHLISTENTRYEPISODE
+ EpisodeMetadata *Episode `json:"episode_metadata"`
+ // not null if Type is WATCHLISTENTRYSERIES
+ SeriesMetadata *Series `json:"series_metadata"`
+}
diff --git a/episode.go b/episode.go
index d0e9b14..f97de31 100644
--- a/episode.go
+++ b/episode.go
@@ -102,43 +102,7 @@ const (
// WatchlistEntry contains information about an entry on the watchlist.
type WatchlistEntry struct {
- Panel struct {
- Title string `json:"title"`
- PromoTitle string `json:"promo_title"`
- Slug string `json:"slug"`
- Playback string `json:"playback"`
- PromoDescription string `json:"promo_description"`
- Images struct {
- Thumbnail [][]struct {
- Height int `json:"height"`
- Source string `json:"source"`
- Type string `json:"type"`
- Width int `json:"width"`
- } `json:"thumbnail"`
- PosterTall [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"poster_tall"`
- PosterWide [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"poster_wide"`
- } `json:"images"`
- ID string `json:"id"`
- Description string `json:"description"`
- ChannelID string `json:"channel_id"`
- Type WatchlistEntryType `json:"type"`
- ExternalID string `json:"external_id"`
- SlugTitle string `json:"slug_title"`
- // not null if Type is WATCHLISTENTRYEPISODE
- EpisodeMetadata *Episode `json:"episode_metadata"`
- // not null if Type is WATCHLISTENTRYSERIES
- SeriesMetadata *Series `json:"series_metadata"`
- }
+ Panel Panel `json:"panel"`
New bool `json:"new"`
NewContent bool `json:"new_content"`
From 8ddb436fac31628aa016e29600fe0cef4f523f61 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 01:03:10 +0200
Subject: [PATCH 43/87] Move WatchlistEntry struct to own file
---
episode.go | 12 ------------
watchlist.go | 13 +++++++++++++
2 files changed, 13 insertions(+), 12 deletions(-)
create mode 100644 watchlist.go
diff --git a/episode.go b/episode.go
index f97de31..7f48e93 100644
--- a/episode.go
+++ b/episode.go
@@ -100,18 +100,6 @@ const (
WATCHLISTENTRYSERIES = "series"
)
-// WatchlistEntry contains information about an entry on the watchlist.
-type WatchlistEntry struct {
- Panel Panel `json:"panel"`
-
- New bool `json:"new"`
- NewContent bool `json:"new_content"`
- IsFavorite bool `json:"is_favorite"`
- NeverWatched bool `json:"never_watched"`
- CompleteStatus bool `json:"complete_status"`
- Playahead uint `json:"playahead"`
-}
-
// EpisodeFromID returns an episode by its api id.
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
diff --git a/watchlist.go b/watchlist.go
new file mode 100644
index 0000000..73fbb3d
--- /dev/null
+++ b/watchlist.go
@@ -0,0 +1,13 @@
+package crunchyroll
+
+// WatchlistEntry contains information about an entry on the watchlist.
+type WatchlistEntry struct {
+ Panel Panel `json:"panel"`
+
+ New bool `json:"new"`
+ NewContent bool `json:"new_content"`
+ IsFavorite bool `json:"is_favorite"`
+ NeverWatched bool `json:"never_watched"`
+ CompleteStatus bool `json:"complete_status"`
+ Playahead uint `json:"playahead"`
+}
From aa088cb318afd60deed167356a2d8938cacccad9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 01:42:57 +0200
Subject: [PATCH 44/87] Fix error printing caused panic
---
error.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/error.go b/error.go
index b4f7329..9bc2ae6 100644
--- a/error.go
+++ b/error.go
@@ -12,6 +12,6 @@ type RequestError struct {
Message string
}
-func (re *RequestError) String() string {
+func (re *RequestError) Error() string {
return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message)
}
From 72484c78af9ec33060e7f5c5b8f64f8625683e69 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 01:56:39 +0200
Subject: [PATCH 45/87] Add crunchylists endpoint
---
crunchylists.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++++
crunchyroll.go | 19 +++++++
2 files changed, 167 insertions(+)
create mode 100644 crunchylists.go
diff --git a/crunchylists.go b/crunchylists.go
new file mode 100644
index 0000000..1117dd7
--- /dev/null
+++ b/crunchylists.go
@@ -0,0 +1,148 @@
+package crunchyroll
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+type CrunchyLists struct {
+ crunchy *Crunchyroll
+
+ Items []*CrunchyListPreview `json:"items"`
+ TotalPublic int `json:"total_public"`
+ TotalPrivate int `json:"total_private"`
+ MaxPrivate int `json:"max_private"`
+}
+
+func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"title": name})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ resp, err := cl.crunchy.requestFull(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ return CrunchyListFromID(cl.crunchy, jsonBody["list_id"].(string))
+}
+
+type CrunchyListPreview struct {
+ crunchy *Crunchyroll
+
+ ListID string `json:"list_id"`
+ IsPublic bool `json:"is_public"`
+ Total int `json:"total"`
+ ModifiedAt time.Time `json:"modified_at"`
+ Title string `json:"title"`
+}
+
+func (clp *CrunchyListPreview) CrunchyList() (*CrunchyList, error) {
+ return CrunchyListFromID(clp.crunchy, clp.ListID)
+}
+
+func CrunchyListFromID(crunchy *Crunchyroll, id string) (*CrunchyList, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale)
+ resp, err := crunchy.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ crunchyList := &CrunchyList{
+ crunchy: crunchy,
+ ID: id,
+ }
+ if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil {
+ return nil, err
+ }
+ for _, item := range crunchyList.Items {
+ item.crunchy = crunchy
+ }
+ return crunchyList, nil
+}
+
+type CrunchyList struct {
+ crunchy *Crunchyroll
+
+ ID string `json:"id"`
+
+ Max int `json:"max"`
+ Total int `json:"total"`
+ Title string `json:"title"`
+ IsPublic bool `json:"is_public"`
+ ModifiedAt time.Time `json:"modified_at"`
+ Items []*CrunchyListItem `json:"items"`
+}
+
+func (cl *CrunchyList) AddSeries(series *Series) error {
+ return cl.AddSeriesFromID(series.ID)
+}
+
+func (cl *CrunchyList) AddSeriesFromID(id string) error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"content_id": id})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = cl.crunchy.requestFull(req)
+ return err
+}
+
+func (cl *CrunchyList) RemoveSeries(series *Series) error {
+ return cl.RemoveSeriesFromID(series.ID)
+}
+
+func (cl *CrunchyList) RemoveSeriesFromID(id string) error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale)
+ _, err := cl.crunchy.request(endpoint, http.MethodDelete)
+ return err
+}
+
+func (cl *CrunchyList) Delete() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
+ _, err := cl.crunchy.request(endpoint, http.MethodDelete)
+ return err
+}
+
+func (cl *CrunchyList) Rename(name string) error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"title": name})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = cl.crunchy.requestFull(req)
+ if err == nil {
+ cl.Title = name
+ }
+ return err
+}
+
+type CrunchyListItem struct {
+ crunchy *Crunchyroll
+
+ ListID string `json:"list_id"`
+ ID string `json:"id"`
+ ModifiedAt time.Time `json:"modified_at"`
+ Panel Panel `json:"panel"`
+}
+
+func (cli *CrunchyListItem) Remove() error {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID)
+ _, err := cli.crunchy.request(endpoint, http.MethodDelete)
+ return err
+}
diff --git a/crunchyroll.go b/crunchyroll.go
index 4c29ab4..b9593f3 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -893,6 +893,25 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl
return watchlistEntries, nil
}
+func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
+ resp, err := c.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ crunchyLists := &CrunchyLists{
+ crunchy: c,
+ }
+ json.NewDecoder(resp.Body).Decode(crunchyLists)
+ for _, item := range crunchyLists.Items {
+ item.crunchy = c
+ }
+
+ return crunchyLists, nil
+}
+
// Account returns information about the currently logged in crunchyroll account.
func (c *Crunchyroll) Account() (*Account, error) {
resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
From 9f6a225caf58dc9b8f35133cc2c9fb4a1af71c94 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 02:00:58 +0200
Subject: [PATCH 46/87] Add better error output
---
crunchyroll.go | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/crunchyroll.go b/crunchyroll.go
index b9593f3..3d07252 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -328,6 +328,11 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
return nil, &RequestError{Response: resp, Message: msg}
}
}
+ } else if _, ok := errMap["code"]; ok {
+ if errContext, ok := errMap["context"]; ok {
+ errField := errContext.([]any)[0].(map[string]any)
+ return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errField["code"].(string), errField["field"].(string))}
+ }
}
}
From c86595d2c6f3a6d07d7798814b074beba13c47c2 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 02:28:03 +0200
Subject: [PATCH 47/87] Add image to common structs
---
common.go | 28 ++++++++++------------------
episode.go | 7 +------
movie_listing.go | 7 +------
video.go | 14 ++------------
4 files changed, 14 insertions(+), 42 deletions(-)
diff --git a/common.go b/common.go
index 91e1312..b76a8fe 100644
--- a/common.go
+++ b/common.go
@@ -1,5 +1,12 @@
package crunchyroll
+type Image struct {
+ Height int `json:"height"`
+ Source string `json:"source"`
+ Type string `json:"type"`
+ Width int `json:"width"`
+}
+
type Panel struct {
Title string `json:"title"`
PromoTitle string `json:"promo_title"`
@@ -7,24 +14,9 @@ type Panel struct {
Playback string `json:"playback"`
PromoDescription string `json:"promo_description"`
Images struct {
- Thumbnail [][]struct {
- Height int `json:"height"`
- Source string `json:"source"`
- Type string `json:"type"`
- Width int `json:"width"`
- } `json:"thumbnail"`
- PosterTall [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"poster_tall"`
- PosterWide [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"poster_wide"`
+ Thumbnail [][]Image `json:"thumbnail"`
+ PosterTall [][]Image `json:"poster_tall"`
+ PosterWide [][]Image `json:"poster_wide"`
} `json:"images"`
ID string `json:"id"`
Description string `json:"description"`
diff --git a/episode.go b/episode.go
index 7f48e93..7d9caac 100644
--- a/episode.go
+++ b/episode.go
@@ -61,12 +61,7 @@ type Episode struct {
Slug string `json:"slug"`
Images struct {
- Thumbnail [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"thumbnail"`
+ Thumbnail [][]Image `json:"thumbnail"`
} `json:"images"`
DurationMS int `json:"duration_ms"`
diff --git a/movie_listing.go b/movie_listing.go
index 110f67a..81cb48d 100644
--- a/movie_listing.go
+++ b/movie_listing.go
@@ -19,12 +19,7 @@ type MovieListing struct {
Description string `json:"description"`
Images struct {
- Thumbnail [][]struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"thumbnail"`
+ Thumbnail [][]Image `json:"thumbnail"`
} `json:"images"`
DurationMS int `json:"duration_ms"`
diff --git a/video.go b/video.go
index 3e7d7cf..3a67f5c 100644
--- a/video.go
+++ b/video.go
@@ -17,18 +17,8 @@ type video struct {
SlugTitle string `json:"slug_title"`
Images struct {
- PosterTall [][]struct {
- Height int `json:"height"`
- Source string `json:"source"`
- Type string `json:"type"`
- Width int `json:"width"`
- } `json:"poster_tall"`
- PosterWide [][]struct {
- Height int `json:"height"`
- Source string `json:"source"`
- Type string `json:"type"`
- Width int `json:"width"`
- } `json:"poster_wide"`
+ PosterTall [][]Image `json:"poster_tall"`
+ PosterWide [][]Image `json:"poster_wide"`
} `json:"images"`
}
From 475dc34f7af0ab57de8def3b0489a2030f9983f9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 13:31:29 +0200
Subject: [PATCH 48/87] Fix error handling caused panic
---
crunchyroll.go | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 3d07252..0544c70 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -331,7 +331,11 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
} else if _, ok := errMap["code"]; ok {
if errContext, ok := errMap["context"]; ok {
errField := errContext.([]any)[0].(map[string]any)
- return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errField["code"].(string), errField["field"].(string))}
+ var code string
+ if code, ok = errField["message"].(string); !ok {
+ code = errField["code"].(string)
+ }
+ return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", code, errField["field"].(string))}
}
}
}
From 0c93893627e4f0906f9de4c9158514bf2fc06016 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 13:38:41 +0200
Subject: [PATCH 49/87] Fix error handling caused panic (again)
---
crunchyroll.go | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 0544c70..8b4fad8 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -329,13 +329,15 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
}
}
} else if _, ok := errMap["code"]; ok {
- if errContext, ok := errMap["context"]; ok {
- errField := errContext.([]any)[0].(map[string]any)
+ if errContext, ok := errMap["context"].([]any); ok && len(errContext) > 0 {
+ errField := errContext[0].(map[string]any)
var code string
if code, ok = errField["message"].(string); !ok {
code = errField["code"].(string)
}
return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", code, errField["field"].(string))}
+ } else if errMessage, ok := errMap["message"].(string); ok {
+ return nil, &RequestError{Response: resp, Message: errMessage}
}
}
}
From cee34105327856a37b5619c67615b1b2cb6a8445 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 19 Jun 2022 14:43:46 +0200
Subject: [PATCH 50/87] Add comment endpoint
---
comment.go | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++
episode.go | 80 ++++++++++++++++
utils.go | 12 +++
3 files changed, 354 insertions(+)
create mode 100644 comment.go
diff --git a/comment.go b/comment.go
new file mode 100644
index 0000000..4dba10d
--- /dev/null
+++ b/comment.go
@@ -0,0 +1,262 @@
+package crunchyroll
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+type Comment struct {
+ crunchy *Crunchyroll
+
+ EpisodeID string `json:"episode_id"`
+
+ CommentID string `json:"comment_id"`
+ DomainID string `json:"domain_id"`
+
+ GuestbookKey string `json:"guestbook_key"`
+
+ User struct {
+ UserKey string `json:"user_key"`
+ UserAttributes struct {
+ Username string `json:"username"`
+ Avatar struct {
+ Locked []Image `json:"locked"`
+ Unlocked []Image `json:"unlocked"`
+ } `json:"avatar"`
+ } `json:"user_attributes"`
+ UserFlags []any `json:"user_flags"`
+ } `json:"user"`
+
+ Message string `json:"message"`
+ ParentCommentID int `json:"parent_comment_id"`
+
+ Locale LOCALE `json:"locale"`
+
+ UserVotes []string `json:"user_votes"`
+ Flags []string `json:"flags"`
+ Votes struct {
+ Inappropriate int `json:"inappropriate"`
+ Like int `json:"like"`
+ Spoiler int `json:"spoiler"`
+ } `json:"votes"`
+
+ DeleteReason any `json:"delete_reason"`
+
+ Created time.Time `json:"created"`
+ Modified time.Time `json:"modified"`
+
+ IsOwner bool `json:"is_owner"`
+ RepliesCount int `json:"replies_count"`
+}
+
+// Delete deleted the current comment. Works only if the user has written the comment.
+func (c *Comment) Delete() error {
+ if !c.IsOwner {
+ return fmt.Errorf("cannot delete, user is not the comment author")
+ }
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
+ resp, err := c.crunchy.request(endpoint, http.MethodDelete)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // the api returns a new comment object when modifying it.
+ // hopefully this does not change
+ json.NewDecoder(resp.Body).Decode(c)
+
+ return nil
+}
+
+// MarkAsSpoiler marks the current comment as spoiler. Works only if the user has written the comment,
+// and it isn't already marked as spoiler.
+func (c *Comment) MarkAsSpoiler() error {
+ if !c.IsOwner {
+ return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
+ } else if c.markedAs("spoiler") {
+ return fmt.Errorf("comment is already marked as spoiler")
+ }
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
+ body, _ := json.Marshal(map[string][]string{"add": {"spoiler"}})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ resp, err := c.crunchy.requestFull(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ json.NewDecoder(resp.Body).Decode(c)
+
+ return nil
+}
+
+// UnmarkAsSpoiler unmarks the current comment as spoiler. Works only if the user has written the comment,
+// and it is already marked as spoiler.
+func (c *Comment) UnmarkAsSpoiler() error {
+ if !c.IsOwner {
+ return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
+ } else if !c.markedAs("spoiler") {
+ return fmt.Errorf("comment is not marked as spoiler")
+ }
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
+ body, _ := json.Marshal(map[string][]string{"remove": {"spoiler"}})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ resp, err := c.crunchy.requestFull(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ json.NewDecoder(resp.Body).Decode(c)
+
+ return nil
+}
+
+// Like likes the comment. Works only if the user hasn't already liked it.
+func (c *Comment) Like() error {
+ if err := c.vote("like", "liked"); err != nil {
+ return err
+ }
+ c.Votes.Like += 1
+
+ return nil
+}
+
+// RemoveLike removes the like from the comment. Works only if the user has liked it.
+func (c *Comment) RemoveLike() error {
+ if err := c.unVote("like", "liked"); err != nil {
+ return err
+ }
+ c.Votes.Like -= 1
+
+ return nil
+}
+
+// Reply replies to the current comment.
+func (c *Comment) Reply(message string, spoiler bool) (*Comment, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", c.EpisodeID, c.crunchy.Locale)
+ var flags []string
+ if spoiler {
+ flags = append(flags, "spoiler")
+ }
+ body, _ := json.Marshal(map[string]any{"locale": string(c.crunchy.Locale), "message": message, "flags": flags, "parent_id": c.CommentID})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ resp, err := c.crunchy.requestFull(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ reply := &Comment{}
+ if err = json.NewDecoder(resp.Body).Decode(reply); err != nil {
+ return nil, err
+ }
+
+ return reply, nil
+}
+
+// Replies shows all replies to the current comment.
+func (c *Comment) Replies(page uint, size uint) ([]*Comment, error) {
+ if c.RepliesCount == 0 {
+ return []*Comment{}, nil
+ }
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/replies?page_size=%d&page=%d&locale=%s", c.EpisodeID, c.CommentID, size, page, c.Locale)
+ resp, err := c.crunchy.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]any
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ var comments []*Comment
+ if err = decodeMapToStruct(jsonBody["items"].([]any), &comments); err != nil {
+ return nil, err
+ }
+ return comments, nil
+}
+
+// Report reports the comment. Only works if the comment hasn't been reported yet.
+func (c *Comment) Report() error {
+ return c.vote("inappropriate", "reported")
+}
+
+// UnreportComment removes the report request from the comment. Only works if the user
+// has reported the comment.
+func (c *Comment) UnreportComment() error {
+ return c.unVote("inappropriate", "reported")
+}
+
+// FlagAsSpoiler sends a request to the user (and / or crunchyroll?) to mark the comment
+// as spoiler. Only works if the comment hasn't been flagged as spoiler yet.
+func (c *Comment) FlagAsSpoiler() error {
+ return c.vote("spoiler", "spoiler")
+}
+
+// UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the
+// comment as spoiler. Only works if the user has flagged the comment as spoiler.
+func (c *Comment) UnflagAsSpoiler() error {
+ return c.unVote("spoiler", "spoiler")
+}
+
+func (c *Comment) markedAs(voteType string) bool {
+ for _, userVote := range c.UserVotes {
+ if userVote == voteType {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Comment) vote(voteType, readableName string) error {
+ if c.markedAs(voteType) {
+ return fmt.Errorf("comment is already marked as %s", readableName)
+ }
+
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
+ body, _ := json.Marshal(map[string]string{"vote_type": voteType})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = c.crunchy.requestFull(req)
+ if err != nil {
+ return err
+ }
+ c.UserVotes = append(c.UserVotes, voteType)
+
+ return nil
+}
+
+func (c *Comment) unVote(voteType, readableName string) error {
+ for i, userVote := range c.UserVotes {
+ if userVote == voteType {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?vote_type=%s&locale=%s", c.EpisodeID, c.CommentID, voteType, c.crunchy.Locale)
+ _, err := c.crunchy.request(endpoint, http.MethodDelete)
+ if err != nil {
+ return err
+ }
+ c.UserVotes = append(c.UserVotes[:i], c.UserVotes[i+1:]...)
+ return nil
+ }
+ }
+
+ return fmt.Errorf("comment is not marked as %s", readableName)
+}
diff --git a/episode.go b/episode.go
index 7d9caac..9e20510 100644
--- a/episode.go
+++ b/episode.go
@@ -167,6 +167,86 @@ func (e *Episode) AudioLocale() (LOCALE, error) {
return streams[0].AudioLocale, nil
}
+// Comment creates a new comment under the episode.
+func (e *Episode) Comment(message string, spoiler bool) (*Comment, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", e.ID, e.crunchy.Locale)
+ var flags []string
+ if spoiler {
+ flags = append(flags, "spoiler")
+ }
+ body, _ := json.Marshal(map[string]any{"locale": string(e.crunchy.Locale), "flags": flags, "message": message})
+ req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ resp, err := e.crunchy.requestFull(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ c := &Comment{
+ crunchy: e.crunchy,
+ EpisodeID: e.ID,
+ }
+ if err = json.NewDecoder(resp.Body).Decode(c); err != nil {
+ return nil, err
+ }
+
+ return c, nil
+}
+
+// CommentsOrderType represents a sort type to sort Episode.Comments after.
+type CommentsOrderType string
+
+const (
+ CommentsOrderAsc CommentsOrderType = "asc"
+ CommentsOrderDesc = "desc"
+)
+
+type CommentsSortType string
+
+const (
+ CommentsSortPopular CommentsSortType = "popular"
+ CommentsSortDate = "date"
+)
+
+type CommentsOptions struct {
+ // Order specified the order how the comments should be returned.
+ Order CommentsOrderType `json:"order"`
+
+ // Sort specified after which key the comments should be sorted.
+ Sort CommentsSortType `json:"sort"`
+}
+
+// Comments returns comments under the given episode.
+func (e *Episode) Comments(options CommentsOptions, page uint, size uint) (c []*Comment, err error) {
+ options, err = structDefaults(CommentsOptions{Order: CommentsOrderDesc, Sort: CommentsSortPopular}, options)
+ if err != nil {
+ return nil, err
+ }
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?page=%d&page_size=%d&order=%s&sort=%s&locale=%s", e.ID, page, size, options.Order, options.Sort, e.crunchy.Locale)
+ resp, err := e.crunchy.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]any
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ if err = decodeMapToStruct(jsonBody["items"].([]any), &c); err != nil {
+ return nil, err
+ }
+ for _, comment := range c {
+ comment.crunchy = e.crunchy
+ comment.EpisodeID = e.ID
+ }
+
+ return
+}
+
// GetFormat returns the format which matches the given resolution and subtitle locale.
func (e *Episode) GetFormat(resolution string, subtitle LOCALE, hardsub bool) (*Format, error) {
streams, err := e.Streams()
diff --git a/utils.go b/utils.go
index d32d39b..a665040 100644
--- a/utils.go
+++ b/utils.go
@@ -1,6 +1,7 @@
package crunchyroll
import (
+ "bytes"
"encoding/json"
"fmt"
"net/url"
@@ -70,3 +71,14 @@ func encodeStructToQueryValues(s interface{}) (string, error) {
return values.Encode(), nil
}
+
+func structDefaults[T any](defaultStruct T, customStruct T) (T, error) {
+ rawDefaultStruct, err := json.Marshal(defaultStruct)
+ if err != nil {
+ return *new(T), err
+ }
+ if err = json.NewDecoder(bytes.NewBuffer(rawDefaultStruct)).Decode(&customStruct); err != nil {
+ return *new(T), err
+ }
+ return customStruct, nil
+}
From 715ade831ca0387e5d45c447201e86bab5389552 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 20 Jun 2022 10:22:17 +0200
Subject: [PATCH 51/87] Add more account and profile endpoints
---
account.go | 116 ++++++++++++++++++++++++++++++++++++++++++++++++-
crunchyroll.go | 4 +-
2 files changed, 117 insertions(+), 3 deletions(-)
diff --git a/account.go b/account.go
index d22eba5..87a6ef8 100644
--- a/account.go
+++ b/account.go
@@ -1,9 +1,16 @@
package crunchyroll
-import "time"
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "time"
+)
// Account contains information about a crunchyroll account.
type Account struct {
+ crunchy *Crunchyroll
+
AccountID string `json:"account_id"`
ExternalID string `json:"external_id"`
EmailVerified bool `json:"email_verified"`
@@ -25,5 +32,110 @@ type Account struct {
PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"`
PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"`
QaUser bool `json:"qa_user"`
- Username string `json:"username"`
+
+ Username string `json:"username"`
+ Wallpaper *Wallpaper `json:"wallpaper"`
+}
+
+// UpdateEmailLanguage sets in which language emails should be received.
+func (a *Account) UpdateEmailLanguage(language LOCALE) error {
+ err := a.updatePreferences("preferred_communication_language", string(language))
+ if err == nil {
+ a.PreferredCommunicationLanguage = language
+ }
+ return err
+}
+
+// UpdateVideoSubtitleLanguage sets in which language default subtitles should be shown
+func (a *Account) UpdateVideoSubtitleLanguage(language LOCALE) error {
+ err := a.updatePreferences("preferred_content_subtitle_language", string(language))
+ if err == nil {
+ a.PreferredContentSubtitleLanguage = language
+ }
+ return err
+}
+
+// UpdateMatureVideoContent sets if mature video content / 18+ content should be shown
+func (a *Account) UpdateMatureVideoContent(enabled bool) error {
+ if enabled {
+ return a.updatePreferences("maturity_rating", "M3")
+ } else {
+ return a.updatePreferences("maturity_rating", "M2")
+ }
+}
+
+// UpdateMatureMangaContent sets if mature manga content / 18+ content should be shown
+func (a *Account) UpdateMatureMangaContent(enabled bool) error {
+ if enabled {
+ return a.updatePreferences("mature_content_flag_manga", "1")
+ } else {
+ return a.updatePreferences("mature_content_flag_manga", "0")
+ }
+}
+
+func (a *Account) updatePreferences(name, value string) error {
+ endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile"
+ body, _ := json.Marshal(map[string]string{name: value})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = a.crunchy.requestFull(req)
+ return err
+}
+
+// ChangePassword changes the password for the current account.
+func (a *Account) ChangePassword(currentPassword, newPassword string) error {
+ endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials"
+ body, _ := json.Marshal(map[string]string{"accountId": a.AccountID, "current_password": currentPassword, "new_password": newPassword})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = a.crunchy.requestFull(req)
+ return err
+}
+
+// ChangeEmail changes the email address for the current account.
+func (a *Account) ChangeEmail(currentPassword, newEmail string) error {
+ endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials"
+ body, _ := json.Marshal(map[string]string{"current_password": currentPassword, "email": newEmail})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Content-Type", "application/json")
+ _, err = a.crunchy.requestFull(req)
+ return err
+}
+
+// AvailableWallpapers returns all available wallpapers which can be set as profile wallpaper.
+func (a *Account) AvailableWallpapers() (w []*Wallpaper, err error) {
+ endpoint := "https://beta.crunchyroll.com/assets/v1/wallpaper"
+ resp, err := a.crunchy.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]any
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ err = decodeMapToStruct(jsonBody["items"].([]any), &w)
+ return
+}
+
+// ChangeWallpaper changes the profile wallpaper of the current user. Use AvailableWallpapers
+// to get all available ones.
+func (a *Account) ChangeWallpaper(wallpaper *Wallpaper) error {
+ endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile"
+ body, _ := json.Marshal(map[string]string{"wallpaper": string(*wallpaper)})
+ req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
+ if err != nil {
+ return err
+ }
+ _, err = a.crunchy.requestFull(req)
+ return err
}
diff --git a/crunchyroll.go b/crunchyroll.go
index 8b4fad8..704003d 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -931,7 +931,9 @@ func (c *Crunchyroll) Account() (*Account, error) {
}
defer resp.Body.Close()
- account := &Account{}
+ account := &Account{
+ crunchy: c,
+ }
if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
From 256c97c2b748ed8e871a713b7d4451c9f56b55d2 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 20 Jun 2022 10:24:01 +0200
Subject: [PATCH 52/87] Fix Wallpaper type not found
---
wallpaper.go | 4 ++++
1 file changed, 4 insertions(+)
create mode 100644 wallpaper.go
diff --git a/wallpaper.go b/wallpaper.go
new file mode 100644
index 0000000..f64cedf
--- /dev/null
+++ b/wallpaper.go
@@ -0,0 +1,4 @@
+package crunchyroll
+
+// Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper.
+type Wallpaper string
From d1859b4c25c3f7f627b50f03f0f5202da681144a Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 21 Jun 2022 17:43:12 +0200
Subject: [PATCH 53/87] Change const names to make them more readable
---
crunchyroll.go | 26 +++++++++++++-------------
episode.go | 4 ++--
2 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 704003d..5de3ab2 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -34,32 +34,32 @@ const (
type MediaType string
const (
- SERIES MediaType = "series"
- MOVIELISTING = "movie_listing"
+ MediaTypeSeries MediaType = "series"
+ MediaTypeMovie = "movie_listing"
)
// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after.
type BrowseSortType string
const (
- BROWSESORTPOPULARITY BrowseSortType = "popularity"
- BROWSESORTNEWLYADDED = "newly_added"
- BROWSESORTALPHABETICAL = "alphabetical"
+ BrowseSortPopularity BrowseSortType = "popularity"
+ BrowseSortNewlyAdded = "newly_added"
+ BrowseSortAlphabetical = "alphabetical"
)
// WatchlistLanguageType represents a filter type to filter Crunchyroll.WatchList entries after.
type WatchlistLanguageType int
const (
- WATCHLISTLANGUAGESUBBED WatchlistLanguageType = iota + 1
- WATCHLISTLANGUAGEDUBBED
+ WatchlistLanguageSubbed WatchlistLanguageType = iota + 1
+ WatchlistLanguageDubbed
)
type WatchlistContentType string
const (
- WATCHLISTCONTENTSERIES WatchlistContentType = "series"
- WATCHLISTCONTENTMOVIES = "movie_listing"
+ WatchlistContentSeries WatchlistContentType = "series"
+ WatchlistContentMovies = "movie_listing"
)
type Crunchyroll struct {
@@ -869,9 +869,9 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl
values.Set("only_favorites", "true")
}
switch options.LanguageType {
- case WATCHLISTLANGUAGESUBBED:
+ case WatchlistLanguageSubbed:
values.Set("is_subbed", "true")
- case WATCHLISTLANGUAGEDUBBED:
+ case WatchlistLanguageDubbed:
values.Set("is_dubbed", "true")
}
values.Set("n", strconv.Itoa(int(limit)))
@@ -894,9 +894,9 @@ func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*Watchl
for _, entry := range watchlistEntries {
switch entry.Panel.Type {
- case WATCHLISTENTRYEPISODE:
+ case WatchlistEntryEpisode:
entry.Panel.EpisodeMetadata.crunchy = c
- case WATCHLISTENTRYSERIES:
+ case WatchlistEntrySeries:
entry.Panel.SeriesMetadata.crunchy = c
}
}
diff --git a/episode.go b/episode.go
index 9e20510..b02280d 100644
--- a/episode.go
+++ b/episode.go
@@ -91,8 +91,8 @@ type HistoryEpisode struct {
type WatchlistEntryType string
const (
- WATCHLISTENTRYEPISODE = "episode"
- WATCHLISTENTRYSERIES = "series"
+ WatchlistEntryEpisode = "episode"
+ WatchlistEntrySeries = "series"
)
// EpisodeFromID returns an episode by its api id.
From ec872d8c86dbfca2757f8ccaa0fa754e21c13703 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 21 Jun 2022 21:15:49 +0200
Subject: [PATCH 54/87] Move functions into their own, separate files & add
docs
---
account.go | 30 +++
category.go | 48 +++-
crunchylists.go | 19 ++
crunchyroll.go | 624 -----------------------------------------------
news.go | 46 +++-
parse.go | 69 ++++++
search.go | 193 +++++++++++++++
simulcast.go | 32 +++
suggestions.go | 82 +++++++
video.go | 43 ++++
watch_history.go | 45 ++++
watchlist.go | 88 +++++++
12 files changed, 681 insertions(+), 638 deletions(-)
create mode 100644 parse.go
create mode 100644 search.go
create mode 100644 suggestions.go
create mode 100644 watch_history.go
diff --git a/account.go b/account.go
index 87a6ef8..f2d6a70 100644
--- a/account.go
+++ b/account.go
@@ -3,10 +3,40 @@ package crunchyroll
import (
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"time"
)
+// Account returns information about the currently logged in crunchyroll account.
+func (c *Crunchyroll) Account() (*Account, error) {
+ resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ account := &Account{
+ crunchy: c,
+ }
+
+ if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
+ return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
+ }
+
+ resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
+ return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
+ }
+
+ return account, nil
+}
+
// Account contains information about a crunchyroll account.
type Account struct {
crunchy *Crunchyroll
diff --git a/category.go b/category.go
index 58855fc..eccefff 100644
--- a/category.go
+++ b/category.go
@@ -1,5 +1,38 @@
package crunchyroll
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// Categories returns all available categories and possible subcategories.
+func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err error) {
+ tenantCategoriesEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/tenant_categories?include_subcategories=%t&locale=%s",
+ includeSubcategories, c.Locale)
+ resp, err := c.request(tenantCategoriesEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, fmt.Errorf("failed to parse 'tenant_categories' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ category := &Category{}
+ if err := decodeMapToStruct(item, category); err != nil {
+ return nil, err
+ }
+
+ ca = append(ca, category)
+ }
+
+ return ca, nil
+}
+
// Category contains all information about a category.
type Category struct {
Category string `json:"tenant_category"`
@@ -18,19 +51,8 @@ type Category struct {
} `json:"sub_categories"`
Images struct {
- Background []struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"background"`
-
- Low []struct {
- Width int `json:"width"`
- Height int `json:"height"`
- Type string `json:"type"`
- Source string `json:"source"`
- } `json:"low"`
+ Background []Image `json:"background"`
+ Low []Image `json:"low"`
} `json:"images"`
Localization struct {
diff --git a/crunchylists.go b/crunchylists.go
index 1117dd7..a2a7a1f 100644
--- a/crunchylists.go
+++ b/crunchylists.go
@@ -8,6 +8,25 @@ import (
"time"
)
+func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
+ resp, err := c.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ crunchyLists := &CrunchyLists{
+ crunchy: c,
+ }
+ json.NewDecoder(resp.Body).Decode(crunchyLists)
+ for _, item := range crunchyLists.Items {
+ item.crunchy = c
+ }
+
+ return crunchyLists, nil
+}
+
type CrunchyLists struct {
crunchy *Crunchyroll
diff --git a/crunchyroll.go b/crunchyroll.go
index 5de3ab2..a83415c 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -8,8 +8,6 @@ import (
"io"
"net/http"
"net/url"
- "regexp"
- "strconv"
"strings"
)
@@ -38,30 +36,6 @@ const (
MediaTypeMovie = "movie_listing"
)
-// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after.
-type BrowseSortType string
-
-const (
- BrowseSortPopularity BrowseSortType = "popularity"
- BrowseSortNewlyAdded = "newly_added"
- BrowseSortAlphabetical = "alphabetical"
-)
-
-// WatchlistLanguageType represents a filter type to filter Crunchyroll.WatchList entries after.
-type WatchlistLanguageType int
-
-const (
- WatchlistLanguageSubbed WatchlistLanguageType = iota + 1
- WatchlistLanguageDubbed
-)
-
-type WatchlistContentType string
-
-const (
- WatchlistContentSeries WatchlistContentType = "series"
- WatchlistContentMovies = "movie_listing"
-)
-
type Crunchyroll struct {
// Client is the http.Client to perform all requests over.
Client *http.Client
@@ -95,30 +69,6 @@ type Crunchyroll struct {
cache bool
}
-// BrowseOptions represents options for browsing the crunchyroll catalog.
-type BrowseOptions struct {
- // Categories specifies the categories of the entries.
- Categories []string `param:"categories"`
-
- // IsDubbed specifies whether the entries should be dubbed.
- IsDubbed bool `param:"is_dubbed"`
-
- // IsSubbed specifies whether the entries should be subbed.
- IsSubbed bool `param:"is_subbed"`
-
- // Simulcast specifies a particular simulcast season by id in which the entries have been aired.
- Simulcast string `param:"season_tag"`
-
- // Sort specifies how the entries should be sorted.
- Sort BrowseSortType `param:"sort_by"`
-
- // Start specifies the index from which the entries should be returned.
- Start uint `param:"start"`
-
- // Type specifies the media type of the entries.
- Type MediaType `param:"type"`
-}
-
type loginResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
@@ -377,577 +327,3 @@ func (c *Crunchyroll) IsCaching() bool {
func (c *Crunchyroll) SetCaching(caching bool) {
c.cache = caching
}
-
-// Search searches a query and returns all found series and movies within the given limit.
-func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, err error) {
- searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s",
- query, limit, c.Locale)
- resp, err := c.request(searchEndpoint, http.MethodGet)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, nil, fmt.Errorf("failed to parse 'search' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- item := item.(map[string]interface{})
- if item["total"].(float64) > 0 {
- switch item["type"] {
- case "series":
- for _, series := range item["items"].([]interface{}) {
- series2 := &Series{
- crunchy: c,
- }
- if err := decodeMapToStruct(series, series2); err != nil {
- return nil, nil, err
- }
- if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil {
- return nil, nil, err
- }
-
- s = append(s, series2)
- }
- case "movie_listing":
- for _, movie := range item["items"].([]interface{}) {
- movie2 := &Movie{
- crunchy: c,
- }
- if err := decodeMapToStruct(movie, movie2); err != nil {
- return nil, nil, err
- }
-
- m = append(m, movie2)
- }
- }
- }
- }
-
- return s, m, nil
-}
-
-// FindVideoByName finds a Video (Season or Movie) by its name.
-// Use this in combination with ParseVideoURL and hand over the corresponding results
-// to this function.
-//
-// Deprecated: Use Search instead. The first result sometimes isn't the correct one
-// so this function is inaccurate in some cases.
-// See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information.
-func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) {
- s, m, err := c.Search(seriesName, 1)
- if err != nil {
- return nil, err
- }
-
- if len(s) > 0 {
- return s[0], nil
- } else if len(m) > 0 {
- return m[0], nil
- }
- return nil, fmt.Errorf("no series or movie could be found")
-}
-
-// FindEpisodeByName finds an episode by its crunchyroll series name and episode title.
-// Use this in combination with ParseEpisodeURL and hand over the corresponding results
-// to this function.
-func (c *Crunchyroll) FindEpisodeByName(seriesName, episodeTitle string) ([]*Episode, error) {
- series, _, err := c.Search(seriesName, 5)
- if err != nil {
- return nil, err
- }
-
- var matchingEpisodes []*Episode
- for _, s := range series {
- seasons, err := s.Seasons()
- if err != nil {
- return nil, err
- }
-
- for _, season := range seasons {
- episodes, err := season.Episodes()
- if err != nil {
- return nil, err
- }
- for _, episode := range episodes {
- if episode.SlugTitle == episodeTitle {
- matchingEpisodes = append(matchingEpisodes, episode)
- }
- }
- }
- }
-
- return matchingEpisodes, nil
-}
-
-// ParseVideoURL tries to extract the crunchyroll series / movie name out of the given url.
-//
-// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaSeriesURL
-// if possible since beta url are always safe to use.
-// The method will stay in the library until only beta urls are supported by crunchyroll itself.
-func ParseVideoURL(url string) (seriesName string, ok bool) {
- pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P[^/]+)(/videos)?/?$`)
- if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
- groups := regexGroups(urlMatch, pattern.SubexpNames()...)
- seriesName = groups["series"]
-
- if seriesName != "" {
- ok = true
- }
- }
- return
-}
-
-// ParseEpisodeURL tries to extract the crunchyroll series name, title, episode number and web id out of the given crunchyroll url
-// Note that the episode number can be misleading. For example if an episode has the episode number 23.5 (slime isekai)
-// the episode number will be 235.
-//
-// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaEpisodeURL
-// if possible since beta url are always safe to use.
-// The method will stay in the library until only beta urls are supported by crunchyroll itself.
-func ParseEpisodeURL(url string) (seriesName, title string, episodeNumber int, webId int, ok bool) {
- pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P[^/]+)/episode-(?P\d+)-(?P.+)-(?P\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\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\w+).*`)
- if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
- groups := regexGroups(urlMatch, pattern.SubexpNames()...)
- episodeId = groups["episodeId"]
- ok = true
- }
- return
-}
-
-// Browse browses the crunchyroll catalog filtered by the specified options and returns all found series and movies within the given limit.
-func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m []*Movie, err error) {
- query, err := encodeStructToQueryValues(options)
- if err != nil {
- return nil, nil, err
- }
-
- browseEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/browse?%s&n=%d&locale=%s",
- query, limit, c.Locale)
- resp, err := c.request(browseEndpoint, http.MethodGet)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, nil, fmt.Errorf("failed to parse 'browse' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- switch item.(map[string]interface{})["type"] {
- case "series":
- series := &Series{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, series); err != nil {
- return nil, nil, err
- }
- if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
- return nil, nil, err
- }
-
- s = append(s, series)
- case "movie_listing":
- movie := &Movie{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, movie); err != nil {
- return nil, nil, err
- }
-
- m = append(m, movie)
- }
- }
-
- return s, m, nil
-}
-
-// Categories returns all available categories and possible subcategories.
-func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err error) {
- tenantCategoriesEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/tenant_categories?include_subcategories=%t&locale=%s",
- includeSubcategories, c.Locale)
- resp, err := c.request(tenantCategoriesEndpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'tenant_categories' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- category := &Category{}
- if err := decodeMapToStruct(item, category); err != nil {
- return nil, err
- }
-
- ca = append(ca, category)
- }
-
- return ca, nil
-}
-
-// Simulcasts returns all available simulcast seasons for the current locale.
-func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) {
- seasonListEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/season_list?locale=%s", c.Locale)
- resp, err := c.request(seasonListEndpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'season_list' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- simulcast := &Simulcast{}
- if err := decodeMapToStruct(item, simulcast); err != nil {
- return nil, err
- }
-
- s = append(s, simulcast)
- }
-
- return s, nil
-}
-
-// News returns the top and latest news from crunchyroll for the current locale within the given limits.
-func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*News, err error) {
- newsFeedEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/news_feed?top_news_n=%d&latest_news_n=%d&locale=%s",
- topLimit, latestLimit, c.Locale)
- resp, err := c.request(newsFeedEndpoint, http.MethodGet)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, nil, fmt.Errorf("failed to parse 'news_feed' response: %w", err)
- }
-
- topNews := jsonBody["top_news"].(map[string]interface{})
- for _, item := range topNews["items"].([]interface{}) {
- topNews := &News{}
- if err := decodeMapToStruct(item, topNews); err != nil {
- return nil, nil, err
- }
-
- t = append(t, topNews)
- }
-
- latestNews := jsonBody["latest_news"].(map[string]interface{})
- for _, item := range latestNews["items"].([]interface{}) {
- latestNews := &News{}
- if err := decodeMapToStruct(item, latestNews); err != nil {
- return nil, nil, err
- }
-
- l = append(l, latestNews)
- }
-
- return t, l, nil
-}
-
-// Recommendations returns series and movie recommendations from crunchyroll based on the currently logged in account within the given limit.
-func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err error) {
- recommendationsEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/recommendations?n=%d&locale=%s",
- c.Config.AccountID, limit, c.Locale)
- resp, err := c.request(recommendationsEndpoint, http.MethodGet)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, nil, fmt.Errorf("failed to parse 'recommendations' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- switch item.(map[string]interface{})["type"] {
- case "series":
- series := &Series{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, series); err != nil {
- return nil, nil, err
- }
- if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
- return nil, nil, err
- }
-
- s = append(s, series)
- case "movie_listing":
- movie := &Movie{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, movie); err != nil {
- return nil, nil, err
- }
-
- m = append(m, movie)
- }
- }
-
- return s, m, nil
-}
-
-// UpNext returns the episodes that are up next based on the currently logged in account within the given limit.
-func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
- upNextAccountEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/up_next_account?n=%d&locale=%s",
- c.Config.AccountID, limit, c.Locale)
- resp, err := c.request(upNextAccountEndpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'up_next_account' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- panel := item.(map[string]interface{})["panel"]
-
- episode := &Episode{
- crunchy: c,
- }
- if err := decodeMapToStruct(panel, episode); err != nil {
- return nil, err
- }
-
- e = append(e, episode)
- }
-
- return e, nil
-}
-
-// SimilarTo returns similar series and movies according to crunchyroll to the one specified by id within the given limit.
-func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie, err error) {
- similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s",
- c.Config.AccountID, id, limit, c.Locale)
- resp, err := c.request(similarToEndpoint, http.MethodGet)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, nil, fmt.Errorf("failed to parse 'similar_to' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- switch item.(map[string]interface{})["type"] {
- case "series":
- series := &Series{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, series); err != nil {
- return nil, nil, err
- }
- if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
- return nil, nil, err
- }
-
- s = append(s, series)
- case "movie_listing":
- movie := &Movie{
- crunchy: c,
- }
- if err := decodeMapToStruct(item, movie); err != nil {
- return nil, nil, err
- }
-
- m = append(m, movie)
- }
- }
-
- return s, m, nil
-}
-
-// WatchHistory returns the history of watched episodes based on the currently logged in account from the given page with the given size.
-func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, err error) {
- watchHistoryEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/watch-history/%s?page=%d&page_size=%d&locale=%s",
- c.Config.AccountID, page, size, c.Locale)
- resp, err := c.request(watchHistoryEndpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
- return nil, fmt.Errorf("failed to parse 'watch-history' response: %w", err)
- }
-
- for _, item := range jsonBody["items"].([]interface{}) {
- panel := item.(map[string]interface{})["panel"]
-
- episode := &Episode{
- crunchy: c,
- }
- if err := decodeMapToStruct(panel, episode); err != nil {
- return nil, err
- }
-
- historyEpisode := &HistoryEpisode{
- Episode: episode,
- }
- if err := decodeMapToStruct(item, historyEpisode); err != nil {
- return nil, err
- }
-
- e = append(e, historyEpisode)
- }
-
- return e, nil
-}
-
-type WatchlistOptions struct {
- // OrderAsc specified whether the results should be order ascending or descending.
- OrderAsc bool
-
- // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned.
- OnlyFavorites bool
-
- // LanguageType specifies whether returning episodes should be only subbed or dubbed.
- LanguageType WatchlistLanguageType
-
- // ContentType specified whether returning videos should only be series episodes or movies.
- // But tbh all movies I've searched on crunchy were flagged as series too, so this
- // parameter is kinda useless.
- ContentType WatchlistContentType
-}
-
-func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) {
- values := url.Values{}
- if options.OrderAsc {
- values.Set("order", "asc")
- } else {
- values.Set("order", "desc")
- }
- if options.OnlyFavorites {
- values.Set("only_favorites", "true")
- }
- switch options.LanguageType {
- case WatchlistLanguageSubbed:
- values.Set("is_subbed", "true")
- case WatchlistLanguageDubbed:
- values.Set("is_dubbed", "true")
- }
- values.Set("n", strconv.Itoa(int(limit)))
- values.Set("locale", string(c.Locale))
-
- endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode())
- resp, err := c.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- var watchlistEntries []*WatchlistEntry
- if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil {
- return nil, err
- }
-
- for _, entry := range watchlistEntries {
- switch entry.Panel.Type {
- case WatchlistEntryEpisode:
- entry.Panel.EpisodeMetadata.crunchy = c
- case WatchlistEntrySeries:
- entry.Panel.SeriesMetadata.crunchy = c
- }
- }
-
- return watchlistEntries, nil
-}
-
-func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) {
- endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
- resp, err := c.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- crunchyLists := &CrunchyLists{
- crunchy: c,
- }
- json.NewDecoder(resp.Body).Decode(crunchyLists)
- for _, item := range crunchyLists.Items {
- item.crunchy = c
- }
-
- return crunchyLists, nil
-}
-
-// Account returns information about the currently logged in crunchyroll account.
-func (c *Crunchyroll) Account() (*Account, error) {
- resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- account := &Account{
- crunchy: c,
- }
-
- if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
- return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
- }
-
- resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
- return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
- }
-
- return account, nil
-}
diff --git a/news.go b/news.go
index d90dd65..074dc0e 100644
--- a/news.go
+++ b/news.go
@@ -1,6 +1,50 @@
package crunchyroll
-// News contains all information about a news.
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// News returns the top and latest news from crunchyroll for the current locale within the given limits.
+func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*News, err error) {
+ newsFeedEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/news_feed?top_news_n=%d&latest_news_n=%d&locale=%s",
+ topLimit, latestLimit, c.Locale)
+ resp, err := c.request(newsFeedEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse 'news_feed' response: %w", err)
+ }
+
+ topNews := jsonBody["top_news"].(map[string]interface{})
+ for _, item := range topNews["items"].([]interface{}) {
+ topNews := &News{}
+ if err := decodeMapToStruct(item, topNews); err != nil {
+ return nil, nil, err
+ }
+
+ t = append(t, topNews)
+ }
+
+ latestNews := jsonBody["latest_news"].(map[string]interface{})
+ for _, item := range latestNews["items"].([]interface{}) {
+ latestNews := &News{}
+ if err := decodeMapToStruct(item, latestNews); err != nil {
+ return nil, nil, err
+ }
+
+ l = append(l, latestNews)
+ }
+
+ return t, l, nil
+}
+
+// News contains all information about news.
type News struct {
Title string `json:"title"`
Link string `json:"link"`
diff --git a/parse.go b/parse.go
new file mode 100644
index 0000000..392e971
--- /dev/null
+++ b/parse.go
@@ -0,0 +1,69 @@
+package crunchyroll
+
+import (
+ "regexp"
+ "strconv"
+)
+
+// ParseBetaSeriesURL tries to extract the season id of the given crunchyroll beta url, pointing to a season.
+func ParseBetaSeriesURL(url string) (seasonId string, ok bool) {
+ pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?series/(?P\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\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[^/]+)(/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\d+).*`)
+ if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
+ groups := regexGroups(urlMatch, pattern.SubexpNames()...)
+ seriesName = groups["series"]
+ episodeNumber, _ = strconv.Atoi(groups["number"])
+ title = groups["title"]
+ webId, _ = strconv.Atoi(groups["webId"])
+
+ if seriesName != "" && title != "" && webId != 0 {
+ ok = true
+ }
+ }
+ return
+}
diff --git a/search.go b/search.go
new file mode 100644
index 0000000..d7f6e76
--- /dev/null
+++ b/search.go
@@ -0,0 +1,193 @@
+package crunchyroll
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after.
+type BrowseSortType string
+
+const (
+ BrowseSortPopularity BrowseSortType = "popularity"
+ BrowseSortNewlyAdded = "newly_added"
+ BrowseSortAlphabetical = "alphabetical"
+)
+
+// BrowseOptions represents options for browsing the crunchyroll catalog.
+type BrowseOptions struct {
+ // Categories specifies the categories of the entries.
+ Categories []string `param:"categories"`
+
+ // IsDubbed specifies whether the entries should be dubbed.
+ IsDubbed bool `param:"is_dubbed"`
+
+ // IsSubbed specifies whether the entries should be subbed.
+ IsSubbed bool `param:"is_subbed"`
+
+ // Simulcast specifies a particular simulcast season by id in which the entries have been aired.
+ Simulcast string `param:"season_tag"`
+
+ // Sort specifies how the entries should be sorted.
+ Sort BrowseSortType `param:"sort_by"`
+
+ // Start specifies the index from which the entries should be returned.
+ Start uint `param:"start"`
+
+ // Type specifies the media type of the entries.
+ Type MediaType `param:"type"`
+}
+
+// Browse browses the crunchyroll catalog filtered by the specified options and returns all found series and movies within the given limit.
+func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m []*Movie, err error) {
+ query, err := encodeStructToQueryValues(options)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ browseEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/browse?%s&n=%d&locale=%s",
+ query, limit, c.Locale)
+ resp, err := c.request(browseEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse 'browse' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ switch item.(map[string]interface{})["type"] {
+ case "series":
+ series := &Series{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(item, series); err != nil {
+ return nil, nil, err
+ }
+ if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
+ return nil, nil, err
+ }
+
+ s = append(s, series)
+ case "movie_listing":
+ movie := &Movie{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(item, movie); err != nil {
+ return nil, nil, err
+ }
+
+ m = append(m, movie)
+ }
+ }
+
+ return s, m, nil
+}
+
+// FindVideoByName finds a Video (Season or Movie) by its name.
+// Use this in combination with ParseVideoURL and hand over the corresponding results
+// to this function.
+//
+// Deprecated: Use Search instead. The first result sometimes isn't the correct one
+// so this function is inaccurate in some cases.
+// See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information.
+func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) {
+ s, m, err := c.Search(seriesName, 1)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(s) > 0 {
+ return s[0], nil
+ } else if len(m) > 0 {
+ return m[0], nil
+ }
+ return nil, fmt.Errorf("no series or movie could be found")
+}
+
+// FindEpisodeByName finds an episode by its crunchyroll series name and episode title.
+// Use this in combination with ParseEpisodeURL and hand over the corresponding results
+// to this function.
+func (c *Crunchyroll) FindEpisodeByName(seriesName, episodeTitle string) ([]*Episode, error) {
+ series, _, err := c.Search(seriesName, 5)
+ if err != nil {
+ return nil, err
+ }
+
+ var matchingEpisodes []*Episode
+ for _, s := range series {
+ seasons, err := s.Seasons()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, season := range seasons {
+ episodes, err := season.Episodes()
+ if err != nil {
+ return nil, err
+ }
+ for _, episode := range episodes {
+ if episode.SlugTitle == episodeTitle {
+ matchingEpisodes = append(matchingEpisodes, episode)
+ }
+ }
+ }
+ }
+
+ return matchingEpisodes, nil
+}
+
+// Search searches a query and returns all found series and movies within the given limit.
+func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, err error) {
+ searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s",
+ query, limit, c.Locale)
+ resp, err := c.request(searchEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse 'search' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ item := item.(map[string]interface{})
+ if item["total"].(float64) > 0 {
+ switch item["type"] {
+ case "series":
+ for _, series := range item["items"].([]interface{}) {
+ series2 := &Series{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(series, series2); err != nil {
+ return nil, nil, err
+ }
+ if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil {
+ return nil, nil, err
+ }
+
+ s = append(s, series2)
+ }
+ case "movie_listing":
+ for _, movie := range item["items"].([]interface{}) {
+ movie2 := &Movie{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(movie, movie2); err != nil {
+ return nil, nil, err
+ }
+
+ m = append(m, movie2)
+ }
+ }
+ }
+ }
+
+ return s, m, nil
+}
diff --git a/simulcast.go b/simulcast.go
index d02f348..e77d2f8 100644
--- a/simulcast.go
+++ b/simulcast.go
@@ -1,5 +1,37 @@
package crunchyroll
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// Simulcasts returns all available simulcast seasons for the current locale.
+func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) {
+ seasonListEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/season_list?locale=%s", c.Locale)
+ resp, err := c.request(seasonListEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, fmt.Errorf("failed to parse 'season_list' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ simulcast := &Simulcast{}
+ if err := decodeMapToStruct(item, simulcast); err != nil {
+ return nil, err
+ }
+
+ s = append(s, simulcast)
+ }
+
+ return s, nil
+}
+
// Simulcast contains all information about a simulcast season.
type Simulcast struct {
ID string `json:"id"`
diff --git a/suggestions.go b/suggestions.go
new file mode 100644
index 0000000..d6bd770
--- /dev/null
+++ b/suggestions.go
@@ -0,0 +1,82 @@
+package crunchyroll
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// Recommendations returns series and movie recommendations from crunchyroll based on the currently logged in account within the given limit.
+func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err error) {
+ recommendationsEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/recommendations?n=%d&locale=%s",
+ c.Config.AccountID, limit, c.Locale)
+ resp, err := c.request(recommendationsEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse 'recommendations' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ switch item.(map[string]interface{})["type"] {
+ case "series":
+ series := &Series{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(item, series); err != nil {
+ return nil, nil, err
+ }
+ if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
+ return nil, nil, err
+ }
+
+ s = append(s, series)
+ case "movie_listing":
+ movie := &Movie{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(item, movie); err != nil {
+ return nil, nil, err
+ }
+
+ m = append(m, movie)
+ }
+ }
+
+ return s, m, nil
+}
+
+// UpNext returns the episodes that are up next based on the currently logged in account within the given limit.
+func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
+ upNextAccountEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/up_next_account?n=%d&locale=%s",
+ c.Config.AccountID, limit, c.Locale)
+ resp, err := c.request(upNextAccountEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, fmt.Errorf("failed to parse 'up_next_account' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ panel := item.(map[string]interface{})["panel"]
+
+ episode := &Episode{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(panel, episode); err != nil {
+ return nil, err
+ }
+
+ e = append(e, episode)
+ }
+
+ return e, nil
+}
diff --git a/video.go b/video.go
index 3a67f5c..2d76499 100644
--- a/video.go
+++ b/video.go
@@ -206,6 +206,49 @@ func (s *Series) RemoveFromWatchlist() error {
return err
}
+// Similar returns similar series and movies to the current series within the given limit.
+func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) {
+ similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s",
+ s.crunchy.Config.AccountID, s.ID, limit, s.crunchy.Locale)
+ resp, err := s.crunchy.request(similarToEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse 'similar_to' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ switch item.(map[string]interface{})["type"] {
+ case "series":
+ series := &Series{
+ crunchy: s.crunchy,
+ }
+ if err := decodeMapToStruct(item, series); err != nil {
+ return nil, nil, err
+ }
+ if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
+ return nil, nil, err
+ }
+
+ ss = append(ss, series)
+ case "movie_listing":
+ movie := &Movie{
+ crunchy: s.crunchy,
+ }
+ if err := decodeMapToStruct(item, movie); err != nil {
+ return nil, nil, err
+ }
+
+ m = append(m, movie)
+ }
+ }
+ return
+}
+
// Seasons returns all seasons of a series.
func (s *Series) Seasons() (seasons []*Season, err error) {
if s.children != nil {
diff --git a/watch_history.go b/watch_history.go
new file mode 100644
index 0000000..d9ce265
--- /dev/null
+++ b/watch_history.go
@@ -0,0 +1,45 @@
+package crunchyroll
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// WatchHistory returns the history of watched episodes based on the currently logged in account from the given page with the given size.
+func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, err error) {
+ watchHistoryEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/watch-history/%s?page=%d&page_size=%d&locale=%s",
+ c.Config.AccountID, page, size, c.Locale)
+ resp, err := c.request(watchHistoryEndpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
+ return nil, fmt.Errorf("failed to parse 'watch-history' response: %w", err)
+ }
+
+ for _, item := range jsonBody["items"].([]interface{}) {
+ panel := item.(map[string]interface{})["panel"]
+
+ episode := &Episode{
+ crunchy: c,
+ }
+ if err := decodeMapToStruct(panel, episode); err != nil {
+ return nil, err
+ }
+
+ historyEpisode := &HistoryEpisode{
+ Episode: episode,
+ }
+ if err := decodeMapToStruct(item, historyEpisode); err != nil {
+ return nil, err
+ }
+
+ e = append(e, historyEpisode)
+ }
+
+ return e, nil
+}
diff --git a/watchlist.go b/watchlist.go
index 73fbb3d..b4db34d 100644
--- a/watchlist.go
+++ b/watchlist.go
@@ -1,5 +1,93 @@
package crunchyroll
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+)
+
+// WatchlistLanguageType represents a filter type to filter Crunchyroll.Watchlist entries after sub or dub.
+type WatchlistLanguageType int
+
+const (
+ WatchlistLanguageSubbed WatchlistLanguageType = iota + 1
+ WatchlistLanguageDubbed
+)
+
+// WatchlistContentType represents a filter type to filter Crunchyroll.Watchlist entries if they're series or movies.
+type WatchlistContentType string
+
+const (
+ WatchlistContentSeries WatchlistContentType = "series"
+ WatchlistContentMovies = "movie_listing"
+)
+
+// WatchlistOptions represents options for receiving the user watchlist.
+type WatchlistOptions struct {
+ // OrderAsc specified whether the results should be order ascending or descending.
+ OrderAsc bool
+
+ // OnlyFavorites specifies whether only episodes which are marked as favorite should be returned.
+ OnlyFavorites bool
+
+ // LanguageType specifies whether returning episodes should be only subbed or dubbed.
+ LanguageType WatchlistLanguageType
+
+ // ContentType specified whether returning videos should only be series episodes or movies.
+ // But tbh all movies I've searched on crunchy were flagged as series too, so this
+ // parameter is kinda useless.
+ ContentType WatchlistContentType
+}
+
+// Watchlist returns the watchlist entries for the currently logged in user.
+func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) {
+ values := url.Values{}
+ if options.OrderAsc {
+ values.Set("order", "asc")
+ } else {
+ values.Set("order", "desc")
+ }
+ if options.OnlyFavorites {
+ values.Set("only_favorites", "true")
+ }
+ switch options.LanguageType {
+ case WatchlistLanguageSubbed:
+ values.Set("is_subbed", "true")
+ case WatchlistLanguageDubbed:
+ values.Set("is_dubbed", "true")
+ }
+ values.Set("n", strconv.Itoa(int(limit)))
+ values.Set("locale", string(c.Locale))
+
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode())
+ resp, err := c.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var jsonBody map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&jsonBody)
+
+ var watchlistEntries []*WatchlistEntry
+ if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil {
+ return nil, err
+ }
+
+ for _, entry := range watchlistEntries {
+ switch entry.Panel.Type {
+ case WatchlistEntryEpisode:
+ entry.Panel.EpisodeMetadata.crunchy = c
+ case WatchlistEntrySeries:
+ entry.Panel.SeriesMetadata.crunchy = c
+ }
+ }
+
+ return watchlistEntries, nil
+}
+
// WatchlistEntry contains information about an entry on the watchlist.
type WatchlistEntry struct {
Panel Panel `json:"panel"`
From f71846628daf690ea7dd0a9ab32760658fd155e9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 21 Jun 2022 22:02:18 +0200
Subject: [PATCH 55/87] Refactor crunchyroll.go
---
crunchyroll.go | 125 +++++++++++++++++++++++++------------------------
1 file changed, 63 insertions(+), 62 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index a83415c..214cbe4 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -36,39 +36,6 @@ const (
MediaTypeMovie = "movie_listing"
)
-type Crunchyroll struct {
- // Client is the http.Client to perform all requests over.
- Client *http.Client
- // Context can be used to stop requests with Client and is context.Background by default.
- Context context.Context
- // Locale specifies in which language all results should be returned / requested.
- Locale LOCALE
- // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
- // this struct in v2 and below).
- EtpRt string
-
- // Config stores parameters which are needed by some api calls.
- Config struct {
- TokenType string
- AccessToken string
-
- Bucket string
-
- CountryCode string
- Premium bool
- Channel string
- Policy string
- Signature string
- KeyPairID string
- AccountID string
- ExternalID string
- MaturityRating string
- }
-
- // If cache is true, internal caching is enabled.
- cache bool
-}
-
type loginResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
@@ -250,6 +217,69 @@ func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *htt
return crunchy, nil
}
+type Crunchyroll struct {
+ // Client is the http.Client to perform all requests over.
+ Client *http.Client
+ // Context can be used to stop requests with Client and is context.Background by default.
+ Context context.Context
+ // Locale specifies in which language all results should be returned / requested.
+ Locale LOCALE
+ // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
+ // this struct in v2 and below).
+ EtpRt string
+
+ // Config stores parameters which are needed by some api calls.
+ Config struct {
+ TokenType string
+ AccessToken string
+
+ Bucket string
+
+ CountryCode string
+ Premium bool
+ Channel string
+ Policy string
+ Signature string
+ KeyPairID string
+ AccountID string
+ ExternalID string
+ MaturityRating string
+ }
+
+ // If cache is true, internal caching is enabled.
+ cache bool
+}
+
+// IsCaching returns if data gets cached or not.
+// See SetCaching for more information.
+func (c *Crunchyroll) IsCaching() bool {
+ return c.cache
+}
+
+// SetCaching enables or disables internal caching of requests made.
+// Caching is enabled by default.
+// If it is disabled the already cached data still gets called.
+// The best way to prevent this is to create a complete new Crunchyroll struct.
+func (c *Crunchyroll) SetCaching(caching bool) {
+ c.cache = caching
+}
+
+// request is a base function which handles simple api requests.
+func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
+ req, err := http.NewRequest(method, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ return c.requestFull(req)
+}
+
+// requestFull is a base function which handles full user controlled api requests.
+func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) {
+ req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
+
+ return request(req, c.Client)
+}
+
func request(req *http.Request, client *http.Client) (*http.Response, error) {
resp, err := client.Do(req)
if err == nil {
@@ -298,32 +328,3 @@ func request(req *http.Request, client *http.Client) (*http.Response, error) {
}
return resp, err
}
-
-// request is a base function which handles api requests.
-func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
- req, err := http.NewRequest(method, endpoint, nil)
- if err != nil {
- return nil, err
- }
- return c.requestFull(req)
-}
-
-func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) {
- req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
-
- return request(req, c.Client)
-}
-
-// IsCaching returns if data gets cached or not.
-// See SetCaching for more information.
-func (c *Crunchyroll) IsCaching() bool {
- return c.cache
-}
-
-// SetCaching enables or disables internal caching of requests made.
-// Caching is enabled by default.
-// If it is disabled the already cached data still gets called.
-// The best way to prevent this is to create a complete new Crunchyroll struct.
-func (c *Crunchyroll) SetCaching(caching bool) {
- c.cache = caching
-}
From 2067c50937497fadf540107e5502ee005e9433b8 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 21 Jun 2022 22:04:22 +0200
Subject: [PATCH 56/87] Add logout endpoint
---
crunchyroll.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/crunchyroll.go b/crunchyroll.go
index 214cbe4..9c786c6 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -250,6 +250,16 @@ type Crunchyroll struct {
cache bool
}
+// Logout logs the user out which invalidates the current session.
+// You have to call a login method again and create a new Crunchyroll instance
+// if you want to perform any further actions since this instance is not usable
+// anymore after calling this.
+func (c *Crunchyroll) Logout() error {
+ endpoint := "https://crunchyroll.com/logout"
+ _, err := c.request(endpoint, http.MethodGet)
+ return err
+}
+
// IsCaching returns if data gets cached or not.
// See SetCaching for more information.
func (c *Crunchyroll) IsCaching() bool {
From bfad0caa9a95fc93b663f6897b25acbf0e596f38 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 13:52:10 +0200
Subject: [PATCH 57/87] Update email language and video sub language setting
function names
---
account.go | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/account.go b/account.go
index f2d6a70..cb45a77 100644
--- a/account.go
+++ b/account.go
@@ -67,8 +67,8 @@ type Account struct {
Wallpaper *Wallpaper `json:"wallpaper"`
}
-// UpdateEmailLanguage sets in which language emails should be received.
-func (a *Account) UpdateEmailLanguage(language LOCALE) error {
+// UpdatePreferredEmailLanguage sets in which language emails should be received.
+func (a *Account) UpdatePreferredEmailLanguage(language LOCALE) error {
err := a.updatePreferences("preferred_communication_language", string(language))
if err == nil {
a.PreferredCommunicationLanguage = language
@@ -76,8 +76,8 @@ func (a *Account) UpdateEmailLanguage(language LOCALE) error {
return err
}
-// UpdateVideoSubtitleLanguage sets in which language default subtitles should be shown
-func (a *Account) UpdateVideoSubtitleLanguage(language LOCALE) error {
+// UpdatePreferredVideoSubtitleLanguage sets in which language default subtitles should be shown
+func (a *Account) UpdatePreferredVideoSubtitleLanguage(language LOCALE) error {
err := a.updatePreferences("preferred_content_subtitle_language", string(language))
if err == nil {
a.PreferredContentSubtitleLanguage = language
From a283ba724785d537c56ce3967dd0f1b2fe489290 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 14:27:15 +0200
Subject: [PATCH 58/87] Add functions to get wallpaper urls
---
wallpaper.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/wallpaper.go b/wallpaper.go
index f64cedf..da7a032 100644
--- a/wallpaper.go
+++ b/wallpaper.go
@@ -1,4 +1,14 @@
package crunchyroll
+import "fmt"
+
// Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper.
type Wallpaper string
+
+func (w *Wallpaper) TinyUrl() string {
+ return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/360x115/%s", *w)
+}
+
+func (w *Wallpaper) BigUrl() string {
+ return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/1920x400/%s", *w)
+}
From 28070bd32dbd8d5f1c126836ce130b5d1a79323a Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 16:44:34 +0200
Subject: [PATCH 59/87] Add function to check if a comment is marked as spoiler
---
comment.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/comment.go b/comment.go
index 4dba10d..47005cf 100644
--- a/comment.go
+++ b/comment.go
@@ -71,6 +71,16 @@ func (c *Comment) Delete() error {
return nil
}
+// IsSpoiler returns if the comment is marked as spoiler or not.
+func (c *Comment) IsSpoiler() bool {
+ for _, flag := range c.Flags {
+ if flag == "spoiler" {
+ return true
+ }
+ }
+ return false
+}
+
// MarkAsSpoiler marks the current comment as spoiler. Works only if the user has written the comment,
// and it isn't already marked as spoiler.
func (c *Comment) MarkAsSpoiler() error {
From ead1db2be8f7bc46c9ce60e7be24acb946a626e0 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 16:50:48 +0200
Subject: [PATCH 60/87] Add function to check if a comment is liked by the
logged-in user
---
comment.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/comment.go b/comment.go
index 47005cf..762b57b 100644
--- a/comment.go
+++ b/comment.go
@@ -143,6 +143,16 @@ func (c *Comment) Like() error {
return nil
}
+// Liked returns if the user has liked the comment.
+func (c *Comment) Liked() bool {
+ for _, flag := range c.Flags {
+ if flag == "liked" {
+ return true
+ }
+ }
+ return false
+}
+
// RemoveLike removes the like from the comment. Works only if the user has liked it.
func (c *Comment) RemoveLike() error {
if err := c.unVote("like", "liked"); err != nil {
From 9919a48e9ae52ea6050537f5a4adca9723b8e4b6 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 16:57:49 +0200
Subject: [PATCH 61/87] Change 'UnreportComment' to 'RemoveReport'
---
comment.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/comment.go b/comment.go
index 762b57b..811433f 100644
--- a/comment.go
+++ b/comment.go
@@ -217,9 +217,9 @@ func (c *Comment) Report() error {
return c.vote("inappropriate", "reported")
}
-// UnreportComment removes the report request from the comment. Only works if the user
+// RemoveReport removes the report request from the comment. Only works if the user
// has reported the comment.
-func (c *Comment) UnreportComment() error {
+func (c *Comment) RemoveReport() error {
return c.unVote("inappropriate", "reported")
}
From f03287856b3f509f9ac1fbe68fe9580deef13a8b Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 16:59:04 +0200
Subject: [PATCH 62/87] Add function to check if comment is reported
---
comment.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/comment.go b/comment.go
index 811433f..98c400c 100644
--- a/comment.go
+++ b/comment.go
@@ -217,6 +217,10 @@ func (c *Comment) Report() error {
return c.vote("inappropriate", "reported")
}
+func (c *Comment) IsReported() bool {
+ return c.markedAs("reported")
+}
+
// RemoveReport removes the report request from the comment. Only works if the user
// has reported the comment.
func (c *Comment) RemoveReport() error {
From 0521895f11e9cbafe49585f1fe65f637f888036c Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 16:59:54 +0200
Subject: [PATCH 63/87] Add function to check if comment is flagged as spoiler
---
comment.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/comment.go b/comment.go
index 98c400c..dc68201 100644
--- a/comment.go
+++ b/comment.go
@@ -233,6 +233,10 @@ func (c *Comment) FlagAsSpoiler() error {
return c.vote("spoiler", "spoiler")
}
+func (c *Comment) IsFlaggedAsSpoiler() bool {
+ return c.markedAs("spoiler")
+}
+
// UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the
// comment as spoiler. Only works if the user has flagged the comment as spoiler.
func (c *Comment) UnflagAsSpoiler() error {
From 4cfcc11e206cda21ffd3ff7c7413bac088a5cdc6 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 17:00:46 +0200
Subject: [PATCH 64/87] Simplified 'Liked'
---
comment.go | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/comment.go b/comment.go
index dc68201..333ec4b 100644
--- a/comment.go
+++ b/comment.go
@@ -145,12 +145,7 @@ func (c *Comment) Like() error {
// Liked returns if the user has liked the comment.
func (c *Comment) Liked() bool {
- for _, flag := range c.Flags {
- if flag == "liked" {
- return true
- }
- }
- return false
+ return c.markedAs("liked")
}
// RemoveLike removes the like from the comment. Works only if the user has liked it.
From 14491ce6c954cdcd486ccc4876027ee003da5aee Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 17:01:08 +0200
Subject: [PATCH 65/87] Rename 'markedAs' to 'votedAs'
---
comment.go | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/comment.go b/comment.go
index 333ec4b..d58fade 100644
--- a/comment.go
+++ b/comment.go
@@ -86,7 +86,7 @@ func (c *Comment) IsSpoiler() bool {
func (c *Comment) MarkAsSpoiler() error {
if !c.IsOwner {
return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
- } else if c.markedAs("spoiler") {
+ } else if c.votedAs("spoiler") {
return fmt.Errorf("comment is already marked as spoiler")
}
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
@@ -112,7 +112,7 @@ func (c *Comment) MarkAsSpoiler() error {
func (c *Comment) UnmarkAsSpoiler() error {
if !c.IsOwner {
return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
- } else if !c.markedAs("spoiler") {
+ } else if !c.votedAs("spoiler") {
return fmt.Errorf("comment is not marked as spoiler")
}
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
@@ -145,7 +145,7 @@ func (c *Comment) Like() error {
// Liked returns if the user has liked the comment.
func (c *Comment) Liked() bool {
- return c.markedAs("liked")
+ return c.votedAs("liked")
}
// RemoveLike removes the like from the comment. Works only if the user has liked it.
@@ -213,7 +213,7 @@ func (c *Comment) Report() error {
}
func (c *Comment) IsReported() bool {
- return c.markedAs("reported")
+ return c.votedAs("reported")
}
// RemoveReport removes the report request from the comment. Only works if the user
@@ -229,7 +229,7 @@ func (c *Comment) FlagAsSpoiler() error {
}
func (c *Comment) IsFlaggedAsSpoiler() bool {
- return c.markedAs("spoiler")
+ return c.votedAs("spoiler")
}
// UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the
@@ -238,7 +238,7 @@ func (c *Comment) UnflagAsSpoiler() error {
return c.unVote("spoiler", "spoiler")
}
-func (c *Comment) markedAs(voteType string) bool {
+func (c *Comment) votedAs(voteType string) bool {
for _, userVote := range c.UserVotes {
if userVote == voteType {
return true
@@ -248,7 +248,7 @@ func (c *Comment) markedAs(voteType string) bool {
}
func (c *Comment) vote(voteType, readableName string) error {
- if c.markedAs(voteType) {
+ if c.votedAs(voteType) {
return fmt.Errorf("comment is already marked as %s", readableName)
}
From e6172cdf90685e5f363c984f77301a34c2dc5076 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 17:16:47 +0200
Subject: [PATCH 66/87] Change watchlist option order type and content type
---
watchlist.go | 20 +++++++++-----------
1 file changed, 9 insertions(+), 11 deletions(-)
diff --git a/watchlist.go b/watchlist.go
index b4db34d..6fa0bc0 100644
--- a/watchlist.go
+++ b/watchlist.go
@@ -16,18 +16,17 @@ const (
WatchlistLanguageDubbed
)
-// WatchlistContentType represents a filter type to filter Crunchyroll.Watchlist entries if they're series or movies.
-type WatchlistContentType string
+type WatchlistOrderType string
const (
- WatchlistContentSeries WatchlistContentType = "series"
- WatchlistContentMovies = "movie_listing"
+ WatchlistOrderAsc = "asc"
+ WatchlistOrderDesc = "desc"
)
// WatchlistOptions represents options for receiving the user watchlist.
type WatchlistOptions struct {
- // OrderAsc specified whether the results should be order ascending or descending.
- OrderAsc bool
+ // Order specified whether the results should be order ascending or descending.
+ Order WatchlistOrderType
// OnlyFavorites specifies whether only episodes which are marked as favorite should be returned.
OnlyFavorites bool
@@ -38,17 +37,16 @@ type WatchlistOptions struct {
// ContentType specified whether returning videos should only be series episodes or movies.
// But tbh all movies I've searched on crunchy were flagged as series too, so this
// parameter is kinda useless.
- ContentType WatchlistContentType
+ ContentType MediaType
}
// Watchlist returns the watchlist entries for the currently logged in user.
func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) {
values := url.Values{}
- if options.OrderAsc {
- values.Set("order", "asc")
- } else {
- values.Set("order", "desc")
+ if options.Order == "" {
+ options.Order = WatchlistOrderDesc
}
+ values.Set("order", string(options.Order))
if options.OnlyFavorites {
values.Set("only_favorites", "true")
}
From 1a4abdc4d817f3a95ca09ee7e249a905ead3ac32 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 17:31:26 +0200
Subject: [PATCH 67/87] Made 'l' in crunchylist lowercase and made
CrunchylistFromID priave
---
crunchylists.go | 86 ++++++++++++++++++++++++-------------------------
1 file changed, 43 insertions(+), 43 deletions(-)
diff --git a/crunchylists.go b/crunchylists.go
index a2a7a1f..b23ae00 100644
--- a/crunchylists.go
+++ b/crunchylists.go
@@ -8,7 +8,7 @@ import (
"time"
)
-func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) {
+func (c *Crunchyroll) Crunchylists() (*Crunchylists, error) {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
resp, err := c.request(endpoint, http.MethodGet)
if err != nil {
@@ -16,27 +16,27 @@ func (c *Crunchyroll) CrunchyLists() (*CrunchyLists, error) {
}
defer resp.Body.Close()
- crunchyLists := &CrunchyLists{
+ crunchylists := &Crunchylists{
crunchy: c,
}
- json.NewDecoder(resp.Body).Decode(crunchyLists)
- for _, item := range crunchyLists.Items {
+ json.NewDecoder(resp.Body).Decode(crunchylists)
+ for _, item := range crunchylists.Items {
item.crunchy = c
}
- return crunchyLists, nil
+ return crunchylists, nil
}
-type CrunchyLists struct {
+type Crunchylists struct {
crunchy *Crunchyroll
- Items []*CrunchyListPreview `json:"items"`
+ Items []*CrunchylistPreview `json:"items"`
TotalPublic int `json:"total_public"`
TotalPrivate int `json:"total_private"`
MaxPrivate int `json:"max_private"`
}
-func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) {
+func (cl *Crunchylists) Create(name string) (*Crunchylist, error) {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"title": name})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
@@ -53,10 +53,10 @@ func (cl *CrunchyLists) Create(name string) (*CrunchyList, error) {
var jsonBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&jsonBody)
- return CrunchyListFromID(cl.crunchy, jsonBody["list_id"].(string))
+ return crunchylistFromID(cl.crunchy, jsonBody["list_id"].(string))
}
-type CrunchyListPreview struct {
+type CrunchylistPreview struct {
crunchy *Crunchyroll
ListID string `json:"list_id"`
@@ -66,32 +66,11 @@ type CrunchyListPreview struct {
Title string `json:"title"`
}
-func (clp *CrunchyListPreview) CrunchyList() (*CrunchyList, error) {
- return CrunchyListFromID(clp.crunchy, clp.ListID)
+func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) {
+ return crunchylistFromID(clp.crunchy, clp.ListID)
}
-func CrunchyListFromID(crunchy *Crunchyroll, id string) (*CrunchyList, error) {
- endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale)
- resp, err := crunchy.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- crunchyList := &CrunchyList{
- crunchy: crunchy,
- ID: id,
- }
- if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil {
- return nil, err
- }
- for _, item := range crunchyList.Items {
- item.crunchy = crunchy
- }
- return crunchyList, nil
-}
-
-type CrunchyList struct {
+type Crunchylist struct {
crunchy *Crunchyroll
ID string `json:"id"`
@@ -101,14 +80,14 @@ type CrunchyList struct {
Title string `json:"title"`
IsPublic bool `json:"is_public"`
ModifiedAt time.Time `json:"modified_at"`
- Items []*CrunchyListItem `json:"items"`
+ Items []*CrunchylistItem `json:"items"`
}
-func (cl *CrunchyList) AddSeries(series *Series) error {
+func (cl *Crunchylist) AddSeries(series *Series) error {
return cl.AddSeriesFromID(series.ID)
}
-func (cl *CrunchyList) AddSeriesFromID(id string) error {
+func (cl *Crunchylist) AddSeriesFromID(id string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"content_id": id})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
@@ -120,23 +99,23 @@ func (cl *CrunchyList) AddSeriesFromID(id string) error {
return err
}
-func (cl *CrunchyList) RemoveSeries(series *Series) error {
+func (cl *Crunchylist) RemoveSeries(series *Series) error {
return cl.RemoveSeriesFromID(series.ID)
}
-func (cl *CrunchyList) RemoveSeriesFromID(id string) error {
+func (cl *Crunchylist) RemoveSeriesFromID(id string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale)
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
return err
}
-func (cl *CrunchyList) Delete() error {
+func (cl *Crunchylist) Delete() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
return err
}
-func (cl *CrunchyList) Rename(name string) error {
+func (cl *Crunchylist) Rename(name string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"title": name})
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
@@ -151,7 +130,28 @@ func (cl *CrunchyList) Rename(name string) error {
return err
}
-type CrunchyListItem struct {
+func crunchylistFromID(crunchy *Crunchyroll, id string) (*Crunchylist, error) {
+ endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale)
+ resp, err := crunchy.request(endpoint, http.MethodGet)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ crunchyList := &Crunchylist{
+ crunchy: crunchy,
+ ID: id,
+ }
+ if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil {
+ return nil, err
+ }
+ for _, item := range crunchyList.Items {
+ item.crunchy = crunchy
+ }
+ return crunchyList, nil
+}
+
+type CrunchylistItem struct {
crunchy *Crunchyroll
ListID string `json:"list_id"`
@@ -160,7 +160,7 @@ type CrunchyListItem struct {
Panel Panel `json:"panel"`
}
-func (cli *CrunchyListItem) Remove() error {
+func (cli *CrunchylistItem) Remove() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID)
_, err := cli.crunchy.request(endpoint, http.MethodDelete)
return err
From fa2321e9e8517ab66a55a95e0d7bfb71c495ec36 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 23 Jun 2022 17:40:29 +0200
Subject: [PATCH 68/87] Rename 'Logout' to 'InvalidateSession'
---
crunchyroll.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crunchyroll.go b/crunchyroll.go
index 9c786c6..a8b3284 100644
--- a/crunchyroll.go
+++ b/crunchyroll.go
@@ -250,11 +250,11 @@ type Crunchyroll struct {
cache bool
}
-// Logout logs the user out which invalidates the current session.
+// InvalidateSession logs the user out which invalidates the current session.
// You have to call a login method again and create a new Crunchyroll instance
// if you want to perform any further actions since this instance is not usable
// anymore after calling this.
-func (c *Crunchyroll) Logout() error {
+func (c *Crunchyroll) InvalidateSession() error {
endpoint := "https://crunchyroll.com/logout"
_, err := c.request(endpoint, http.MethodGet)
return err
From 2569ddd1c7ed1a6c3006fd87a5bc79bff2e11661 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 24 Jun 2022 11:34:02 +0200
Subject: [PATCH 69/87] Add docs to crunchylists
---
crunchylists.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/crunchylists.go b/crunchylists.go
index b23ae00..a297da2 100644
--- a/crunchylists.go
+++ b/crunchylists.go
@@ -8,6 +8,7 @@ import (
"time"
)
+// Crunchylists returns a struct to control crunchylists.
func (c *Crunchyroll) Crunchylists() (*Crunchylists, error) {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
resp, err := c.request(endpoint, http.MethodGet)
@@ -36,6 +37,7 @@ type Crunchylists struct {
MaxPrivate int `json:"max_private"`
}
+// Create creates a new crunchylist with the given name. Duplicate names for lists are allowed.
func (cl *Crunchylists) Create(name string) (*Crunchylist, error) {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"title": name})
@@ -66,6 +68,7 @@ type CrunchylistPreview struct {
Title string `json:"title"`
}
+// Crunchylist returns the belonging Crunchylist struct.
func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) {
return crunchylistFromID(clp.crunchy, clp.ListID)
}
@@ -83,10 +86,12 @@ type Crunchylist struct {
Items []*CrunchylistItem `json:"items"`
}
+// AddSeries adds a series.
func (cl *Crunchylist) AddSeries(series *Series) error {
return cl.AddSeriesFromID(series.ID)
}
+// AddSeriesFromID adds a series from its id
func (cl *Crunchylist) AddSeriesFromID(id string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"content_id": id})
@@ -99,22 +104,26 @@ func (cl *Crunchylist) AddSeriesFromID(id string) error {
return err
}
+// RemoveSeries removes a series
func (cl *Crunchylist) RemoveSeries(series *Series) error {
return cl.RemoveSeriesFromID(series.ID)
}
+// RemoveSeriesFromID removes a series by its id
func (cl *Crunchylist) RemoveSeriesFromID(id string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale)
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
return err
}
+// Delete deleted the current crunchylist.
func (cl *Crunchylist) Delete() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
return err
}
+// Rename renames the current crunchylist.
func (cl *Crunchylist) Rename(name string) error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
body, _ := json.Marshal(map[string]string{"title": name})
@@ -160,6 +169,7 @@ type CrunchylistItem struct {
Panel Panel `json:"panel"`
}
+// Remove removes the current item from its crunchylist.
func (cli *CrunchylistItem) Remove() error {
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID)
_, err := cli.crunchy.request(endpoint, http.MethodDelete)
From 3dcfbc0fbb5c00caad58d3309145356e21ea019b Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 24 Jun 2022 11:51:12 +0200
Subject: [PATCH 70/87] Add docs to wallpaper
---
wallpaper.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/wallpaper.go b/wallpaper.go
index da7a032..e99bafb 100644
--- a/wallpaper.go
+++ b/wallpaper.go
@@ -5,10 +5,12 @@ import "fmt"
// Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper.
type Wallpaper string
+// TinyUrl returns the url to the wallpaper in low resolution.
func (w *Wallpaper) TinyUrl() string {
return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/360x115/%s", *w)
}
+// BigUrl returns the url to the wallpaper in high resolution.
func (w *Wallpaper) BigUrl() string {
return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/1920x400/%s", *w)
}
From 79c3ba2636a188697a6161d0d68b4ba8cdedc664 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 24 Jun 2022 11:51:48 +0200
Subject: [PATCH 71/87] Refactor to use consts instead of string
---
search.go | 8 ++++----
suggestions.go | 4 ++--
video.go | 4 ++--
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/search.go b/search.go
index d7f6e76..cbbf7e9 100644
--- a/search.go
+++ b/search.go
@@ -61,7 +61,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m
for _, item := range jsonBody["items"].([]interface{}) {
switch item.(map[string]interface{})["type"] {
- case "series":
+ case MediaTypeSeries:
series := &Series{
crunchy: c,
}
@@ -73,7 +73,7 @@ func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m
}
s = append(s, series)
- case "movie_listing":
+ case MediaTypeMovie:
movie := &Movie{
crunchy: c,
}
@@ -160,7 +160,7 @@ func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie,
item := item.(map[string]interface{})
if item["total"].(float64) > 0 {
switch item["type"] {
- case "series":
+ case MediaTypeSeries:
for _, series := range item["items"].([]interface{}) {
series2 := &Series{
crunchy: c,
@@ -174,7 +174,7 @@ func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie,
s = append(s, series2)
}
- case "movie_listing":
+ case MediaTypeMovie:
for _, movie := range item["items"].([]interface{}) {
movie2 := &Movie{
crunchy: c,
diff --git a/suggestions.go b/suggestions.go
index d6bd770..35d7a9b 100644
--- a/suggestions.go
+++ b/suggestions.go
@@ -23,7 +23,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err
for _, item := range jsonBody["items"].([]interface{}) {
switch item.(map[string]interface{})["type"] {
- case "series":
+ case MediaTypeSeries:
series := &Series{
crunchy: c,
}
@@ -35,7 +35,7 @@ func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err
}
s = append(s, series)
- case "movie_listing":
+ case MediaTypeMovie:
movie := &Movie{
crunchy: c,
}
diff --git a/video.go b/video.go
index 2d76499..04ea5e9 100644
--- a/video.go
+++ b/video.go
@@ -223,7 +223,7 @@ func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) {
for _, item := range jsonBody["items"].([]interface{}) {
switch item.(map[string]interface{})["type"] {
- case "series":
+ case MediaTypeSeries:
series := &Series{
crunchy: s.crunchy,
}
@@ -235,7 +235,7 @@ func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) {
}
ss = append(ss, series)
- case "movie_listing":
+ case MediaTypeMovie:
movie := &Movie{
crunchy: s.crunchy,
}
From 0fed0f8d3be02ecd395b4ece8467e40b2b08c2c9 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 27 Jun 2022 22:31:36 +0200
Subject: [PATCH 72/87] Change license to GPL-3.0
---
LICENSE | 687 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 650 insertions(+), 37 deletions(-)
diff --git a/LICENSE b/LICENSE
index 36688ae..f288702 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,61 +1,674 @@
-Copyright © 2007 Free Software Foundation, Inc.
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
-Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
-This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
-0. Additional Definitions.
+ Preamble
-As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License.
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
-“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
-An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
-A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”.
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
-The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
-The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
-1. Exception to Section 3 of the GNU GPL.
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
-You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
-2. Conveying Modified Versions.
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
-If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
- a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
- b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
-3. Object Code Incorporating Material from Library Header Files.
+ The precise terms and conditions for copying, distribution and
+modification follow.
-The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
+ TERMS AND CONDITIONS
- a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
- b) Accompany the object code with a copy of the GNU GPL and this license document.
+ 0. Definitions.
-4. Combined Works.
+ "This License" refers to version 3 of the GNU General Public License.
-You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
- a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
- b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
- c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
- d) Do one of the following:
- 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
- 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
- e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
-5. Combined Libraries.
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
-You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
- a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
- b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
-6. Revised Versions of the GNU Lesser General Public License.
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
-The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
-Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
+ 1. Source Code.
-If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
From 8a3e42e4d1cb3b4447d878ee04867030d401f9b4 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 27 Jun 2022 22:33:26 +0200
Subject: [PATCH 73/87] Remove library & refactor cli
---
.github/workflows/ci.yml | 4 +-
Makefile | 22 +-
README.md | 80 ++--
account.go | 171 --------
category.go | 65 ---
cmd/crunchyroll-go/main.go | 9 -
.../cmd => commands}/archive.go | 4 +-
.../cmd => commands}/download.go | 4 +-
{cmd/crunchyroll-go/cmd => commands}/info.go | 2 +-
.../crunchyroll-go/cmd => commands}/logger.go | 2 +-
{cmd/crunchyroll-go/cmd => commands}/login.go | 20 +-
{cmd/crunchyroll-go/cmd => commands}/root.go | 8 +-
{cmd/crunchyroll-go/cmd => commands}/unix.go | 2 +-
.../crunchyroll-go/cmd => commands}/update.go | 16 +-
{cmd/crunchyroll-go/cmd => commands}/utils.go | 4 +-
.../cmd => commands}/windows.go | 2 +-
comment.go | 285 -------------
common.go | 31 --
crunchyroll-go.1 => crunchy-cli.1 | 49 ++-
crunchylists.go | 177 --------
crunchyroll.go | 327 ---------------
downloader.go | 395 ------------------
episode.go | 342 ---------------
error.go | 17 -
format.go | 52 ---
go.mod | 3 +-
go.sum | 2 +
main.go | 7 +
movie_listing.go | 92 ----
news.go | 55 ---
parse.go | 69 ---
search.go | 193 ---------
season.go | 135 ------
simulcast.go | 45 --
stream.go | 129 ------
subtitle.go | 32 --
suggestions.go | 82 ----
url.go | 128 ------
utils.go | 84 ----
utils/locale.go | 60 ---
utils/sort.go | 158 -------
video.go | 280 -------------
wallpaper.go | 16 -
watch_history.go | 45 --
watchlist.go | 99 -----
45 files changed, 117 insertions(+), 3687 deletions(-)
delete mode 100644 account.go
delete mode 100644 category.go
delete mode 100644 cmd/crunchyroll-go/main.go
rename {cmd/crunchyroll-go/cmd => commands}/archive.go (99%)
rename {cmd/crunchyroll-go/cmd => commands}/download.go (99%)
rename {cmd/crunchyroll-go/cmd => commands}/info.go (98%)
rename {cmd/crunchyroll-go/cmd => commands}/logger.go (99%)
rename {cmd/crunchyroll-go/cmd => commands}/login.go (88%)
rename {cmd/crunchyroll-go/cmd => commands}/root.go (92%)
rename {cmd/crunchyroll-go/cmd => commands}/unix.go (98%)
rename {cmd/crunchyroll-go/cmd => commands}/update.go (87%)
rename {cmd/crunchyroll-go/cmd => commands}/utils.go (99%)
rename {cmd/crunchyroll-go/cmd => commands}/windows.go (97%)
delete mode 100644 comment.go
delete mode 100644 common.go
rename crunchyroll-go.1 => crunchy-cli.1 (78%)
delete mode 100644 crunchylists.go
delete mode 100644 crunchyroll.go
delete mode 100644 downloader.go
delete mode 100644 episode.go
delete mode 100644 error.go
delete mode 100644 format.go
create mode 100644 main.go
delete mode 100644 movie_listing.go
delete mode 100644 news.go
delete mode 100644 parse.go
delete mode 100644 search.go
delete mode 100644 season.go
delete mode 100644 simulcast.go
delete mode 100644 stream.go
delete mode 100644 subtitle.go
delete mode 100644 suggestions.go
delete mode 100644 url.go
delete mode 100644 utils.go
delete mode 100644 utils/locale.go
delete mode 100644 utils/sort.go
delete mode 100644 video.go
delete mode 100644 wallpaper.go
delete mode 100644 watch_history.go
delete mode 100644 watchlist.go
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c54dd3..b2409a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
go-version: 1.18
- name: Build
- run: go build -v cmd/crunchyroll-go/main.go
+ run: go build -v .
- name: Test
- run: go test -v cmd/crunchyroll-go/main.go
+ run: go test -v .
diff --git a/Makefile b/Makefile
index a747c1d..4266a91 100644
--- a/Makefile
+++ b/Makefile
@@ -6,26 +6,26 @@ DESTDIR=
PREFIX=/usr
build:
- go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(BINARY_NAME) cmd/crunchyroll-go/main.go
+ go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(BINARY_NAME) .
clean:
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
install:
- install -Dm755 $(BINARY_NAME) $(DESTDIR)$(PREFIX)/bin/crunchyroll-go
- ln -sf ./crunchyroll-go $(DESTDIR)$(PREFIX)/bin/crunchy
- install -Dm644 crunchyroll-go.1 $(DESTDIR)$(PREFIX)/share/man/man1/crunchyroll-go.1
- install -Dm644 LICENSE $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
+ install -Dm755 $(BINARY_NAME) $(DESTDIR)$(PREFIX)/bin/crunchy-cli
+ ln -sf ./crunchy-cli $(DESTDIR)$(PREFIX)/bin/crunchy
+ install -Dm644 crunchy-cli.1 $(DESTDIR)$(PREFIX)/share/man/man1/crunchy-cli.1
+ install -Dm644 LICENSE $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
uninstall:
- rm -f $(DESTDIR)$(PREFIX)/bin/crunchyroll-go
+ rm -f $(DESTDIR)$(PREFIX)/bin/crunchy-cli
rm -f $(DESTDIR)$(PREFIX)/bin/crunchy
- rm -f $(DESTDIR)$(PREFIX)/share/man/man1/crunchyroll-go.1
- rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
+ rm -f $(DESTDIR)$(PREFIX)/share/man/man1/crunchy-cli.1
+ rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
release:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux cmd/crunchyroll-go/main.go
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe cmd/crunchyroll-go/main.go
- CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin cmd/crunchyroll-go/main.go
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
+ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
strip $(VERSION_BINARY_NAME)_linux
diff --git a/README.md b/README.md
index 46862f0..bce05b9 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,42 @@
-# crunchyroll-go
+# crunchy-cli
-A [Go](https://golang.org) library & cli for the undocumented [crunchyroll](https://www.crunchyroll.com) api. To use it, you need a crunchyroll premium account to for full (api) access.
+A [go](https://golang.org) written cli client for [crunchyroll](https://www.crunchyroll.com). To use it, you need a crunchyroll premium account to for full (api) access.
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
CLI 🖥️
•
- Library 📚
- •
Disclaimer ☝️
•
License ⚖
+_This repo was former known as **crunchyroll-go** (which still exists but now contains only the library part) but got split up into two separate repositories to provide more flexibility.
+See #39 for more information._
+
# 🖥️ CLI
## ✨ Features
@@ -46,37 +47,42 @@ A [Go](https://golang.org) library & cli for the undocumented [crunchyroll](http
## 💾 Get the executable
-- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchyroll-go/releases/latest) or get it from below:
- - [Linux (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_linux)
- - [Windows (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_windows.exe)
- - [MacOS (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_darwin)
+- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchy-cli/releases/latest) or get it from below:
+ - [Linux (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_linux)
+ - [Windows (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_windows.exe)
+ - [MacOS (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_darwin)
- If you use Arch btw. or any other Linux distro which is based on Arch Linux, you can download the package via the [AUR](https://aur.archlinux.org/packages/crunchyroll-go/):
```shell
$ yay -S crunchyroll-go
```
-- On Windows [scoop](https://scoop.sh/) can be used to install it (added by [@AdmnJ](https://github.com/AdmnJ)):
+-
+
+ On Windows [scoop](https://scoop.sh/) can be used to install it (added by [@AdmnJ](https://github.com/AdmnJ)):
```shell
$ scoop bucket add extras # <- in case you haven't added the extra repository already
$ scoop install crunchyroll-go
```
-- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/ByteDream/crunchyroll-go/releases/latest) (like Raspberry Pi or M1 Mac):
- - use `make` (requires `go` to be installed):
+
+
+ Currently not working because the repo got renamed!
+- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/ByteDream/crunchy-cli/releases/latest) (like Raspberry Pi or M1 Mac):
+ - use `make` (requires `go` to be installed):
```shell
- $ git clone https://github.com/ByteDream/crunchyroll-go
- $ cd crunchyroll-go
+ $ git clone https://github.com/ByteDream/crunchy-cli
+ $ cd crunchy-cli
$ make
$ sudo make install # <- only if you want to install it on your system
```
- - use `go`:
+ - use `go`:
```shell
- $ git clone https://github.com/ByteDream/crunchyroll-go
- $ cd crunchyroll-go
- $ go build -o crunchy cmd/crunchyroll-go/main.go
+ $ git clone https://github.com/ByteDream/crunchy-cli
+ $ cd crunchy-cli
+ $ go build -o crunchy .
```
## 📝 Examples
-_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Cli), further usages and options are described there.
+_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli), further usages and options are described there.
### Login
@@ -163,7 +169,7 @@ The following flags can be (optional) passed to modify the [archive](#archive) p
| `-l` | `--language` | Audio locale which should be downloaded. Can be used multiple times. |
| `-d` | `--directory` | Directory to download the video(s) to. |
| `-o` | `--output` | Name of the output file. |
-| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Cli#archive) for more information. |
+| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli#archive) for more information. |
| `-c` | `--compress` | If is set, all output will be compresses into an archive. This flag sets the name of the compressed output file and the file ending specifies the compression algorithm (gzip, tar, zip are supported). |
| `-r` | `--resolution` | The resolution of the video(s). `best` for best resolution, `worst` for worst. |
| `-g` | `--goroutines` | Sets how many parallel segment downloads should be used. |
@@ -198,18 +204,6 @@ These flags you can use across every sub-command:
| `-v` | Shows additional debug output. |
| `-p` | Use a proxy to hide your ip / redirect your traffic. |
-# 📚 Library
-
-Download the library via `go get`
-
-```shell
-$ go get github.com/ByteDream/crunchyroll-go/v3
-```
-
-The documentation is available on [pkg.go.dev](https://pkg.go.dev/github.com/ByteDream/crunchyroll-go/v3).
-
-Examples how to use the library and some features of it are described in the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Library).
-
# ☝️ Disclaimer
This tool is **ONLY** meant to be used for private purposes. To use this tool you need crunchyroll premium anyway, so there is no reason why rip and share the episodes.
@@ -218,4 +212,4 @@ This tool is **ONLY** meant to be used for private purposes. To use this tool yo
# ⚖ License
-This project is licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0) - see the [LICENSE](LICENSE) file for more details.
+This project is licensed under the GNU General Public License v3.0 (GPL-3.0) - see the [LICENSE](LICENSE) file for more details.
diff --git a/account.go b/account.go
deleted file mode 100644
index cb45a77..0000000
--- a/account.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
- "time"
-)
-
-// Account returns information about the currently logged in crunchyroll account.
-func (c *Crunchyroll) Account() (*Account, error) {
- resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- account := &Account{
- crunchy: c,
- }
-
- if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
- return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
- }
-
- resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
- return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
- }
-
- return account, nil
-}
-
-// Account contains information about a crunchyroll account.
-type Account struct {
- crunchy *Crunchyroll
-
- AccountID string `json:"account_id"`
- ExternalID string `json:"external_id"`
- EmailVerified bool `json:"email_verified"`
- Created time.Time `json:"created"`
-
- Avatar string `json:"avatar"`
- CrBetaOptIn bool `json:"cr_beta_opt_in"`
- Email string `json:"email"`
- MatureContentFlagManga string `json:"mature_content_flag_manga"`
- MaturityRating string `json:"maturity_rating"`
- OptOutAndroidInAppMarketing bool `json:"opt_out_android_in_app_marketing"`
- OptOutFreeTrials bool `json:"opt_out_free_trials"`
- OptOutNewMediaQueueUpdates bool `json:"opt_out_new_media_queue_updates"`
- OptOutNewsletters bool `json:"opt_out_newsletters"`
- OptOutPmUpdates bool `json:"opt_out_pm_updates"`
- OptOutPromotionalUpdates bool `json:"opt_out_promotional_updates"`
- OptOutQueueUpdates bool `json:"opt_out_queue_updates"`
- OptOutStoreDeals bool `json:"opt_out_store_deals"`
- PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"`
- PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"`
- QaUser bool `json:"qa_user"`
-
- 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
deleted file mode 100644
index eccefff..0000000
--- a/category.go
+++ /dev/null
@@ -1,65 +0,0 @@
-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"`
-
- SubCategories []struct {
- Category string `json:"tenant_category"`
- ParentCategory string `json:"parent_category"`
-
- Localization struct {
- Title string `json:"title"`
- Description string `json:"description"`
- Locale LOCALE `json:"locale"`
- } `json:"localization"`
-
- Slug string `json:"slug"`
- } `json:"sub_categories"`
-
- Images struct {
- Background []Image `json:"background"`
- Low []Image `json:"low"`
- } `json:"images"`
-
- Localization struct {
- Title string `json:"title"`
- Description string `json:"description"`
- Locale LOCALE `json:"locale"`
- } `json:"localization"`
-
- Slug string `json:"slug"`
-}
diff --git a/cmd/crunchyroll-go/main.go b/cmd/crunchyroll-go/main.go
deleted file mode 100644
index 0ced8aa..0000000
--- a/cmd/crunchyroll-go/main.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package main
-
-import (
- "github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd"
-)
-
-func main() {
- cmd.Execute()
-}
diff --git a/cmd/crunchyroll-go/cmd/archive.go b/commands/archive.go
similarity index 99%
rename from cmd/crunchyroll-go/cmd/archive.go
rename to commands/archive.go
index 0d47917..5ad6900 100644
--- a/cmd/crunchyroll-go/cmd/archive.go
+++ b/commands/archive.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"archive/tar"
@@ -188,7 +188,7 @@ func archive(urls []string) error {
out.StopProgress("Failed to parse url %d", i+1)
if crunchy.Config.Premium {
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
}
return err
}
diff --git a/cmd/crunchyroll-go/cmd/download.go b/commands/download.go
similarity index 99%
rename from cmd/crunchyroll-go/cmd/download.go
rename to commands/download.go
index 74cc3f2..82b68bf 100644
--- a/cmd/crunchyroll-go/cmd/download.go
+++ b/commands/download.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"context"
@@ -134,7 +134,7 @@ func download(urls []string) error {
out.StopProgress("Failed to parse url %d", i+1)
if crunchy.Config.Premium {
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
}
return err
}
diff --git a/cmd/crunchyroll-go/cmd/info.go b/commands/info.go
similarity index 98%
rename from cmd/crunchyroll-go/cmd/info.go
rename to commands/info.go
index f5ed995..fe9693d 100644
--- a/cmd/crunchyroll-go/cmd/info.go
+++ b/commands/info.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"fmt"
diff --git a/cmd/crunchyroll-go/cmd/logger.go b/commands/logger.go
similarity index 99%
rename from cmd/crunchyroll-go/cmd/logger.go
rename to commands/logger.go
index 83bc214..fab8515 100644
--- a/cmd/crunchyroll-go/cmd/logger.go
+++ b/commands/logger.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"fmt"
diff --git a/cmd/crunchyroll-go/cmd/login.go b/commands/login.go
similarity index 88%
rename from cmd/crunchyroll-go/cmd/login.go
rename to commands/login.go
index d8cc5c3..a710bb6 100644
--- a/cmd/crunchyroll-go/cmd/login.go
+++ b/commands/login.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"bytes"
@@ -122,13 +122,13 @@ func loginCredentials(user, password string) error {
credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
}
- os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), credentials, 0600); err != nil {
+ os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
+ if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), credentials, 0600); err != nil {
return err
}
if !loginEncryptFlag {
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+
- "To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ "To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchy-cli", "crunchy"))
}
}
}
@@ -157,11 +157,11 @@ func loginSessionID(sessionID string) error {
if configDir, err := os.UserConfigDir(); err != nil {
return fmt.Errorf("could not save credentials persistent: %w", err)
} else {
- os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
+ os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
+ if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
return err
}
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
}
}
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
@@ -187,11 +187,11 @@ func loginEtpRt(etpRt string) error {
if configDir, err := os.UserConfigDir(); err != nil {
return fmt.Errorf("could not save credentials persistent: %w", err)
} else {
- os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(etpRt), 0600); err != nil {
+ os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
+ if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(etpRt), 0600); err != nil {
return err
}
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
+ out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
}
}
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil {
diff --git a/cmd/crunchyroll-go/cmd/root.go b/commands/root.go
similarity index 92%
rename from cmd/crunchyroll-go/cmd/root.go
rename to commands/root.go
index 02873f1..ffcf35a 100644
--- a/cmd/crunchyroll-go/cmd/root.go
+++ b/commands/root.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"context"
@@ -27,9 +27,9 @@ var (
)
var rootCmd = &cobra.Command{
- Use: "crunchyroll-go",
+ Use: "crunchy-cli",
Version: Version,
- Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchyroll-go/wiki",
+ Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki",
SilenceErrors: true,
SilenceUsage: true,
@@ -54,7 +54,7 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
- rootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchyroll-go/%s", Version), "Useragent to do all request with")
+ rootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", Version), "Useragent to do all request with")
}
func Execute() {
diff --git a/cmd/crunchyroll-go/cmd/unix.go b/commands/unix.go
similarity index 98%
rename from cmd/crunchyroll-go/cmd/unix.go
rename to commands/unix.go
index 962088f..955695c 100644
--- a/cmd/crunchyroll-go/cmd/unix.go
+++ b/commands/unix.go
@@ -1,6 +1,6 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
-package cmd
+package commands
import (
"bufio"
diff --git a/cmd/crunchyroll-go/cmd/update.go b/commands/update.go
similarity index 87%
rename from cmd/crunchyroll-go/cmd/update.go
rename to commands/update.go
index c8512e6..fb4c1e9 100644
--- a/cmd/crunchyroll-go/cmd/update.go
+++ b/commands/update.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"encoding/json"
@@ -38,7 +38,7 @@ func init() {
func update() error {
var release map[string]interface{}
- resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchyroll-go/releases/latest")
+ resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
if err != nil {
return err
}
@@ -80,20 +80,20 @@ func update() error {
return nil
}
- out.Info("A new version is available (%s): https://github.com/ByteDream/crunchyroll-go/releases/tag/v%s", releaseVersion, releaseVersion)
+ out.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
if updateInstallFlag {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
- "You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOARCH)
+ "You have to update manually (https://github.com/ByteDream/crunchy-cli)", runtime.GOARCH)
}
var downloadFile string
switch runtime.GOOS {
case "linux":
- yayCommand := exec.Command("pacman -Q crunchyroll-go")
+ yayCommand := exec.Command("pacman -Q crunchy-cli")
if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
- out.Info("crunchyroll-go was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
+ out.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
return nil
}
downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion)
@@ -103,7 +103,7 @@ func update() error {
downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
default:
return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
- "You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOOS)
+ "You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS)
}
out.SetProgress("Updating executable %s", os.Args[0])
@@ -119,7 +119,7 @@ func update() error {
}
defer executeFile.Close()
- resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchyroll-go/releases/download/v%s/%s", releaseVersion, downloadFile))
+ resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
if err != nil {
return err
}
diff --git a/cmd/crunchyroll-go/cmd/utils.go b/commands/utils.go
similarity index 99%
rename from cmd/crunchyroll-go/cmd/utils.go
rename to commands/utils.go
index 12d3d4e..486c61f 100644
--- a/cmd/crunchyroll-go/cmd/utils.go
+++ b/commands/utils.go
@@ -1,4 +1,4 @@
-package cmd
+package commands
import (
"crypto/aes"
@@ -161,7 +161,7 @@ func loadCrunchy() {
}
if configDir, err := os.UserConfigDir(); err == nil {
- persistentFilePath := filepath.Join(configDir, "crunchyroll-go", "crunchy")
+ persistentFilePath := filepath.Join(configDir, "crunchy-cli", "crunchy")
if _, statErr := os.Stat(persistentFilePath); statErr == nil {
body, err := os.ReadFile(persistentFilePath)
if err != nil {
diff --git a/cmd/crunchyroll-go/cmd/windows.go b/commands/windows.go
similarity index 97%
rename from cmd/crunchyroll-go/cmd/windows.go
rename to commands/windows.go
index d6eecb1..2ae47b5 100644
--- a/cmd/crunchyroll-go/cmd/windows.go
+++ b/commands/windows.go
@@ -1,6 +1,6 @@
//go:build windows
-package cmd
+package commands
import (
"bufio"
diff --git a/comment.go b/comment.go
deleted file mode 100644
index d58fade..0000000
--- a/comment.go
+++ /dev/null
@@ -1,285 +0,0 @@
-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
deleted file mode 100644
index b76a8fe..0000000
--- a/common.go
+++ /dev/null
@@ -1,31 +0,0 @@
-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/crunchyroll-go.1 b/crunchy-cli.1
similarity index 78%
rename from crunchyroll-go.1
rename to crunchy-cli.1
index 576c291..d9b1075 100644
--- a/crunchyroll-go.1
+++ b/crunchy-cli.1
@@ -1,24 +1,24 @@
-.TH crunchyroll-go 1 "21 March 2022" "crunchyroll-go" "Crunchyroll Downloader"
+.TH crunchy-cli 1 "27 June 2022" "crunchy-cli" "Crunchyroll Cli Client"
.SH NAME
-crunchyroll-go - A cli for downloading videos and entire series from crunchyroll.
+crunchy-cli - A cli for downloading videos and entire series from crunchyroll.
.SH SYNOPSIS
-crunchyroll-go [\fB-h\fR] [\fB-p\fR \fIPROXY\fR] [\fB-q\fR] [\fB-v\fR]
+crunchy-cli [\fB-h\fR] [\fB-p\fR \fIPROXY\fR] [\fB-q\fR] [\fB-v\fR]
.br
-crunchyroll-go help
+crunchy-cli help
.br
-crunchyroll-go login [\fB--persistent\fR] [\fB--session-id\fR \fISESSION_ID\fR] [\fIusername\fR, \fIpassword\fR]
+crunchy-cli login [\fB--persistent\fR] [\fB--session-id\fR \fISESSION_ID\fR] [\fIusername\fR, \fIpassword\fR]
.br
-crunchyroll-go download [\fB-a\fR \fIAUDIO\fR] [\fB-s\fR \fISUBTITLE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
+crunchy-cli download [\fB-a\fR \fIAUDIO\fR] [\fB-s\fR \fISUBTITLE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
.br
-crunchyroll-go archive [\fB-l\fR \fILANGUAGE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-m\fR \fIMERGE BEHAVIOR\fR] [\fB-c\fR \fICOMPRESS\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
+crunchy-cli archive [\fB-l\fR \fILANGUAGE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-m\fR \fIMERGE BEHAVIOR\fR] [\fB-c\fR \fICOMPRESS\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
.br
-crunchyroll-go update [\fB-i\fR \fIINSTALL\fR]
+crunchy-cli update [\fB-i\fR \fIINSTALL\fR]
.SH DESCRIPTION
.TP
-With \fBcrunchyroll-go\fR you can easily download video and series from crunchyroll.
+With \fBcrunchy-cli\fR you can easily download video and series from crunchyroll.
.TP
Note that you need an \fBcrunchyroll premium\fR account in order to use this tool!
@@ -167,27 +167,27 @@ The \fBS\fR, followed by the number indicates the season number, \fBE\fR, follow
.SH EXAMPLES
Login via crunchyroll account email and password.
.br
-$ crunchyroll-go login user@example.com 12345678
+$ crunchy-cli login user@example.com 12345678
Download a episode normally. Your system locale will be used for the video's audio.
.br
-$ crunchyroll-go download https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
+$ crunchy-cli download https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
Download a episode with 720p and name it to 'darling.mp4'. Note that you need \fBffmpeg\fR to save files which do not have '.ts' as file extension.
.br
-$ crunchyroll-go download -o "darling.mp4" -r 720p https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
+$ crunchy-cli download -o "darling.mp4" -r 720p https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
Download a episode with japanese audio and american subtitles.
.br
-$ crunchyroll-go download -a ja-JP -s en-US https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E3-E5]
+$ crunchy-cli download -a ja-JP -s en-US https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E3-E5]
Stores the episode in a .mkv file.
.br
-$ crunchyroll-go archive https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
+$ crunchy-cli archive https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
Downloads the first two episode of Darling in the FranXX and stores it compressed in a file.
.br
-$ crunchyroll-go archive -c "ditf.tar.gz" https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E1-E2]
+$ crunchy-cli archive -c "ditf.tar.gz" https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E1-E2]
.SH BUGS
If you notice any bug or want an enhancement, feel free to create a new issue or pull request in the GitHub repository.
@@ -195,22 +195,21 @@ If you notice any bug or want an enhancement, feel free to create a new issue or
.SH AUTHOR
ByteDream
.br
-Source: https://github.com/ByteDream/crunchyroll-go
+Source: https://github.com/ByteDream/crunchy-cli
.SH COPYRIGHT
Copyright (C) 2022 ByteDream
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU Lesser General Public
-License as published by the Free Software Foundation; either
-version 3 of the License, or (at your option) any later version.
+This program is free software: you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation, either version 3
+of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
-Lesser General Public License for more details.
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
-You should have received a copy of the GNU Lesser General Public License
-along with this program; if not, write to the Free Software Foundation,
-Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
diff --git a/crunchylists.go b/crunchylists.go
deleted file mode 100644
index a297da2..0000000
--- a/crunchylists.go
+++ /dev/null
@@ -1,177 +0,0 @@
-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
deleted file mode 100644
index c697034..0000000
--- a/crunchyroll.go
+++ /dev/null
@@ -1,327 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
-)
-
-// LOCALE represents a locale / language.
-type LOCALE string
-
-const (
- JP LOCALE = "ja-JP"
- US = "en-US"
- LA = "es-419"
- ES = "es-ES"
- FR = "fr-FR"
- PT = "pt-PT"
- BR = "pt-BR"
- IT = "it-IT"
- DE = "de-DE"
- RU = "ru-RU"
- AR = "ar-SA"
-)
-
-// MediaType represents a media type.
-type MediaType string
-
-const (
- MediaTypeSeries MediaType = "series"
- MediaTypeMovie = "movie_listing"
-)
-
-type loginResponse struct {
- AccessToken string `json:"access_token"`
- ExpiresIn int `json:"expires_in"`
- TokenType string `json:"token_type"`
- Scope string `json:"scope"`
- Country string `json:"country"`
- AccountID string `json:"account_id"`
-}
-
-// LoginWithCredentials logs in via crunchyroll username or email and password.
-func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
- values := url.Values{}
- values.Set("username", user)
- values.Set("password", password)
- values.Set("grant_type", "password")
-
- req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(values.Encode()))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", "Basic aHJobzlxM2F3dnNrMjJ1LXRzNWE6cHROOURteXRBU2Z6QjZvbXVsSzh6cUxzYTczVE1TY1k=")
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
- resp, err := request(req, client)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- var loginResp loginResponse
- json.NewDecoder(resp.Body).Decode(&loginResp)
-
- var etpRt string
- for _, cookie := range resp.Cookies() {
- if cookie.Name == "etp_rt" {
- etpRt = cookie.Value
- break
- }
- }
-
- return postLogin(loginResp, etpRt, locale, client)
-}
-
-// LoginWithSessionID logs in via a crunchyroll session id.
-// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com.
-//
-// Deprecated: Login via session id caused some trouble in the past (e.g. #15 or #30) which resulted in
-// login not working. Use LoginWithEtpRt instead. EtpRt practically the crunchyroll beta equivalent to
-// a session id.
-// The method will stay in the library until session id login is removed completely or login with it
-// does not work for a longer period of time.
-func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- endpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
- sessionID)
- resp, err := client.Get(endpoint)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to start session: %s", resp.Status)
- }
-
- var jsonBody map[string]any
- 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"])
- }
-
- var etpRt string
- for _, cookie := range resp.Cookies() {
- if cookie.Name == "etp_rt" {
- etpRt = cookie.Value
- break
- }
- }
-
- return LoginWithEtpRt(etpRt, locale, client)
-}
-
-// LoginWithEtpRt logs in via the crunchyroll etp rt cookie. This cookie is the crunchyroll beta
-// equivalent to the classic session id.
-// The etp_rt cookie is automatically set when visiting https://beta.crunchyroll.com. Note that you
-// need a crunchyroll account to access it.
-func LoginWithEtpRt(etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
- grantType := url.Values{}
- grantType.Set("grant_type", "etp_rt_cookie")
-
- req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(grantType.Encode()))
- if err != nil {
- return nil, err
- }
- req.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.AddCookie(&http.Cookie{
- Name: "etp_rt",
- Value: etpRt,
- })
- resp, err := request(req, client)
- if err != nil {
- return nil, err
- }
-
- var loginResp loginResponse
- json.NewDecoder(resp.Body).Decode(&loginResp)
-
- return postLogin(loginResp, etpRt, locale, client)
-}
-
-func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
- crunchy := &Crunchyroll{
- Client: client,
- Context: context.Background(),
- Locale: locale,
- EtpRt: etpRt,
- cache: true,
- }
-
- crunchy.Config.TokenType = loginResp.TokenType
- crunchy.Config.AccessToken = loginResp.AccessToken
- crunchy.Config.AccountID = loginResp.AccountID
- crunchy.Config.CountryCode = loginResp.Country
-
- var jsonBody map[string]any
-
- endpoint := "https://beta-api.crunchyroll.com/index/v2"
- resp, err := crunchy.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- cms := jsonBody["cms"].(map[string]any)
- 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), "/")
- crunchy.Config.Policy = cms["policy"].(string)
- crunchy.Config.Signature = cms["signature"].(string)
- crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
-
- endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me"
- resp, err = crunchy.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- json.NewDecoder(resp.Body).Decode(&jsonBody)
- crunchy.Config.ExternalID = jsonBody["external_id"].(string)
-
- endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/profile"
- resp, err = crunchy.request(endpoint, http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- json.NewDecoder(resp.Body).Decode(&jsonBody)
- crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string)
-
- return crunchy, nil
-}
-
-type Crunchyroll struct {
- // Client is the http.Client to perform all requests over.
- Client *http.Client
- // Context can be used to stop requests with Client and is context.Background by default.
- Context context.Context
- // Locale specifies in which language all results should be returned / requested.
- Locale LOCALE
- // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
- // this struct in v2 and below).
- EtpRt string
-
- // Config stores parameters which are needed by some api calls.
- Config struct {
- TokenType string
- AccessToken string
-
- Bucket string
-
- CountryCode string
- Premium bool
- Channel string
- Policy string
- Signature string
- KeyPairID string
- AccountID string
- ExternalID string
- MaturityRating string
- }
-
- // If cache is true, internal caching is enabled.
- cache bool
-}
-
-// 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.
-// See SetCaching for more information.
-func (c *Crunchyroll) IsCaching() bool {
- return c.cache
-}
-
-// SetCaching enables or disables internal caching of requests made.
-// Caching is enabled by default.
-// If it is disabled the already cached data still gets called.
-// The best way to prevent this is to create a complete new Crunchyroll struct.
-func (c *Crunchyroll) SetCaching(caching bool) {
- c.cache = caching
-}
-
-// request is a base function which handles simple api requests.
-func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
- req, err := http.NewRequest(method, endpoint, nil)
- if err != nil {
- return nil, err
- }
- return c.requestFull(req)
-}
-
-// requestFull is a base function which handles full user controlled api requests.
-func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) {
- req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
-
- return request(req, c.Client)
-}
-
-func request(req *http.Request, client *http.Client) (*http.Response, error) {
- resp, err := client.Do(req)
- if err == nil {
- 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}
- }
- }
- } 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)
- }
- 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}
- }
- }
- }
-
- if resp.StatusCode >= 400 {
- return nil, &RequestError{Response: resp, Message: resp.Status}
- }
- }
- return resp, err
-}
diff --git a/downloader.go b/downloader.go
deleted file mode 100644
index 05622cc..0000000
--- a/downloader.go
+++ /dev/null
@@ -1,395 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "context"
- "crypto/aes"
- "crypto/cipher"
- "fmt"
- "github.com/grafov/m3u8"
- "io"
- "math"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-)
-
-// NewDownloader creates a downloader with default settings which should
-// fit the most needs.
-func NewDownloader(context context.Context, writer io.Writer, goroutines int, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File) error) Downloader {
- tmp, _ := os.MkdirTemp("", "crunchy_")
-
- return Downloader{
- Writer: writer,
- TempDir: tmp,
- DeleteTempAfter: true,
- Context: context,
- Goroutines: goroutines,
- OnSegmentDownload: onSegmentDownload,
- }
-}
-
-// Downloader is used to download Format's
-type Downloader struct {
- // The output is all written to Writer.
- Writer io.Writer
-
- // TempDir is the directory where the temporary segment files should be stored.
- // The files will be placed directly into the root of the directory.
- // If empty a random temporary directory on the system's default tempdir
- // will be created.
- // If the directory does not exist, it will be created.
- TempDir string
- // If DeleteTempAfter is true, the temp directory gets deleted afterwards.
- // Note that in case of a hard signal exit (os.Interrupt, ...) the directory
- // will NOT be deleted. In such situations try to catch the signal and
- // cancel Context.
- DeleteTempAfter bool
-
- // Context to control the download process with.
- // There is a tiny delay when canceling the context and the actual stop of the
- // process. So it is not recommend stopping the program immediately after calling
- // the cancel function. It's better when canceling it and then exit the program
- // when Format.Download throws an error. See the signal handling section in
- // cmd/crunchyroll-go/cmd/download.go for an example.
- Context context.Context
-
- // Goroutines is the number of goroutines to download segments with.
- Goroutines int
-
- // A method to call when a segment was downloaded.
- // Note that the segments are downloaded asynchronously (depending on the count of
- // Goroutines) and the function gets called asynchronously too, so for example it is
- // first called on segment 1, then segment 254, then segment 3 and so on.
- OnSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File) error
- // If LockOnSegmentDownload is true, only one OnSegmentDownload function can be called at
- // once. Normally (because of the use of goroutines while downloading) multiple could get
- // called simultaneously.
- LockOnSegmentDownload bool
-
- // If FFmpegOpts is not nil, ffmpeg will be used to merge and convert files.
- // The given opts will be used as ffmpeg parameters while merging.
- //
- // If Writer is *os.File and -f (which sets the output format) is not specified, the output
- // format will be retrieved by its file ending. If this is not the case and -f is not given,
- // the output format will be mpegts / mpeg transport stream.
- // Execute 'ffmpeg -muxers' to see all available output formats.
- FFmpegOpts []string
-}
-
-// download downloads the given format.
-func (d Downloader) download(format *Format) error {
- if err := format.InitVideo(); err != nil {
- return err
- }
-
- if _, err := os.Stat(d.TempDir); os.IsNotExist(err) {
- if err = os.Mkdir(d.TempDir, 0700); err != nil {
- return err
- }
- }
- if d.DeleteTempAfter {
- defer os.RemoveAll(d.TempDir)
- }
-
- files, err := d.downloadSegments(format)
- if err != nil {
- return err
- }
- if d.FFmpegOpts == nil {
- return d.mergeSegments(files)
- } else {
- return d.mergeSegmentsFFmpeg(files)
- }
-}
-
-// mergeSegments reads every file in tempDir and writes their content to Downloader.Writer.
-// The given output file gets created or overwritten if already existing.
-func (d Downloader) mergeSegments(files []string) error {
- for _, file := range files {
- select {
- case <-d.Context.Done():
- return d.Context.Err()
- default:
- f, err := os.Open(file)
- if err != nil {
- return err
- }
- if _, err = io.Copy(d.Writer, f); err != nil {
- f.Close()
- return err
- }
- f.Close()
- }
- }
- return nil
-}
-
-// mergeSegmentsFFmpeg reads every file in tempDir and merges their content to the outputFile
-// with ffmpeg (https://ffmpeg.org/).
-// The given output file gets created or overwritten if already existing.
-func (d Downloader) mergeSegmentsFFmpeg(files []string) error {
- list, err := os.Create(filepath.Join(d.TempDir, "list.txt"))
- if err != nil {
- return err
- }
-
- for _, file := range files {
- if _, err = fmt.Fprintf(list, "file '%s'\n", file); err != nil {
- list.Close()
- return err
- }
- }
- list.Close()
-
- // predefined options ... custom options ... predefined output filename
- command := []string{
- "-y",
- "-f", "concat",
- "-safe", "0",
- "-i", list.Name(),
- "-c", "copy",
- }
- if d.FFmpegOpts != nil {
- command = append(command, d.FFmpegOpts...)
- }
-
- var tmpfile string
- if _, ok := d.Writer.(*io.PipeWriter); !ok {
- if file, ok := d.Writer.(*os.File); ok {
- tmpfile = file.Name()
- }
- }
- if filepath.Ext(tmpfile) == "" {
- // checks if the -f flag is set (overwrites the output format)
- var hasF bool
- for _, opts := range d.FFmpegOpts {
- if strings.TrimSpace(opts) == "-f" {
- hasF = true
- break
- }
- }
- if !hasF {
- command = append(command, "-f", "matroska")
- f, err := os.CreateTemp(d.TempDir, "")
- if err != nil {
- return err
- }
- f.Close()
- tmpfile = f.Name()
- }
- }
- command = append(command, tmpfile)
-
- var errBuf bytes.Buffer
- cmd := exec.CommandContext(d.Context, "ffmpeg",
- command...)
- cmd.Stderr = &errBuf
-
- if err = cmd.Run(); err != nil {
- if errBuf.Len() > 0 {
- return fmt.Errorf(errBuf.String())
- } else {
- return err
- }
- }
- if f, ok := d.Writer.(*os.File); !ok || f.Name() != tmpfile {
- var file *os.File
- if file, err = os.Open(tmpfile); err != nil {
- return err
- }
- defer file.Close()
- _, err = io.Copy(d.Writer, file)
- }
- return err
-}
-
-// downloadSegments downloads every mpeg transport stream segment to a given
-// directory (more information below).
-// After every segment download onSegmentDownload will be called with:
-// the downloaded segment, the current position, the total size of segments to download,
-// the file where the segment content was written to an error (if occurred).
-// The filename is always .ts.
-//
-// Short explanation:
-// The actual crunchyroll video is split up in multiple segments (or video files) which
-// have to be downloaded and merged after to generate a single video file.
-// And this function just downloads each of this segment into the given directory.
-// See https://en.wikipedia.org/wiki/MPEG_transport_stream for more information.
-func (d Downloader) downloadSegments(format *Format) ([]string, error) {
- if err := format.InitVideo(); err != nil {
- return nil, err
- }
-
- var wg sync.WaitGroup
- var lock sync.Mutex
- chunkSize := int(math.Ceil(float64(format.Video.Chunklist.Count()) / float64(d.Goroutines)))
-
- // when a onSegmentDownload call returns an error, this context will be set cancelled and stop all goroutines
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // receives the decrypt block and iv from the first segment.
- // in my tests, only the first segment has specified this data, so the decryption data from this first segments will be used in every other segment too
- block, iv, err := getCrypt(format, format.Video.Chunklist.Segments[0])
- if err != nil {
- return nil, err
- }
-
- var total int32
- for i := 0; i < int(format.Video.Chunklist.Count()); i += chunkSize {
- wg.Add(1)
- end := i + chunkSize
- if end > int(format.Video.Chunklist.Count()) {
- end = int(format.Video.Chunklist.Count())
- }
- i := i
-
- go func() {
- defer wg.Done()
-
- for j, segment := range format.Video.Chunklist.Segments[i:end] {
- select {
- case <-d.Context.Done():
- case <-ctx.Done():
- return
- default:
- var file *os.File
- for k := 0; k < 3; k++ {
- filename := filepath.Join(d.TempDir, fmt.Sprintf("%d.ts", i+j))
- file, err = d.downloadSegment(format, segment, filename, block, iv)
- if err == nil {
- break
- }
- if k == 2 {
- file.Close()
- cancel()
- return
- }
- select {
- case <-d.Context.Done():
- case <-ctx.Done():
- file.Close()
- return
- case <-time.After(5 * time.Duration(k) * time.Second):
- // sleep if an error occurs. very useful because sometimes the connection times out
- }
- }
- if d.OnSegmentDownload != nil {
- if d.LockOnSegmentDownload {
- lock.Lock()
- }
-
- if err = d.OnSegmentDownload(segment, int(atomic.AddInt32(&total, 1)), int(format.Video.Chunklist.Count()), file); err != nil {
- if d.LockOnSegmentDownload {
- lock.Unlock()
- }
- file.Close()
- return
- }
- if d.LockOnSegmentDownload {
- lock.Unlock()
- }
- }
- file.Close()
- }
- }
- }()
- }
- wg.Wait()
-
- select {
- case <-d.Context.Done():
- return nil, d.Context.Err()
- case <-ctx.Done():
- return nil, err
- default:
- var files []string
- for i := 0; i < int(total); i++ {
- files = append(files, filepath.Join(d.TempDir, fmt.Sprintf("%d.ts", i)))
- }
-
- return files, nil
- }
-}
-
-// getCrypt extracts the key and iv of a m3u8 segment and converts it into a cipher.Block and an iv byte sequence.
-func getCrypt(format *Format, segment *m3u8.MediaSegment) (block cipher.Block, iv []byte, err error) {
- var resp *http.Response
-
- resp, err = format.crunchy.Client.Get(segment.Key.URI)
- if err != nil {
- return nil, nil, err
- }
- defer resp.Body.Close()
- key, err := io.ReadAll(resp.Body)
-
- block, err = aes.NewCipher(key)
- if err != nil {
- return nil, nil, err
- }
- iv = []byte(segment.Key.IV)
- if len(iv) == 0 {
- iv = key
- }
-
- return block, iv, nil
-}
-
-// downloadSegment downloads a segment, decrypts it and names it after the given index.
-func (d Downloader) downloadSegment(format *Format, segment *m3u8.MediaSegment, filename string, block cipher.Block, iv []byte) (*os.File, error) {
- // every segment is aes-128 encrypted and has to be decrypted when downloaded
- content, err := d.decryptSegment(format.crunchy.Client, segment, block, iv)
- if err != nil {
- return nil, err
- }
-
- file, err := os.Create(filename)
- if err != nil {
- return nil, err
- }
- defer file.Close()
- if _, err = file.Write(content); err != nil {
- return nil, err
- }
-
- return file, nil
-}
-
-// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L25.
-func (d Downloader) decryptSegment(client *http.Client, segment *m3u8.MediaSegment, block cipher.Block, iv []byte) ([]byte, error) {
- req, err := http.NewRequestWithContext(d.Context, http.MethodGet, segment.URI, nil)
- if err != nil {
- return nil, err
- }
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- raw, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
-
- blockMode := cipher.NewCBCDecrypter(block, iv[:block.BlockSize()])
- decrypted := make([]byte, len(raw))
- blockMode.CryptBlocks(decrypted, raw)
- raw = d.pkcs5UnPadding(decrypted)
-
- return raw, nil
-}
-
-// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L47.
-func (d Downloader) pkcs5UnPadding(origData []byte) []byte {
- length := len(origData)
- unPadding := int(origData[length-1])
- return origData[:(length - unPadding)]
-}
diff --git a/episode.go b/episode.go
deleted file mode 100644
index caf1e09..0000000
--- a/episode.go
+++ /dev/null
@@ -1,342 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-// Episode contains all information about an episode.
-type Episode struct {
- crunchy *Crunchyroll
-
- children []*Stream
-
- ID string `json:"id"`
- ChannelID string `json:"channel_id"`
-
- SeriesID string `json:"series_id"`
- SeriesTitle string `json:"series_title"`
- SeriesSlugTitle string `json:"series_slug_title"`
-
- SeasonID string `json:"season_id"`
- SeasonTitle string `json:"season_title"`
- SeasonSlugTitle string `json:"season_slug_title"`
- SeasonNumber int `json:"season_number"`
-
- Episode string `json:"episode"`
- EpisodeNumber int `json:"episode_number"`
- SequenceNumber float64 `json:"sequence_number"`
- ProductionEpisodeID string `json:"production_episode_id"`
-
- Title string `json:"title"`
- SlugTitle string `json:"slug_title"`
- Description string `json:"description"`
- NextEpisodeID string `json:"next_episode_id"`
- NextEpisodeTitle string `json:"next_episode_title"`
-
- 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"`
- FreeAvailableDate time.Time `json:"free_available_date"`
- PremiumAvailableDate time.Time `json:"premium_available_date"`
-
- IsSubbed bool `json:"is_subbed"`
- IsDubbed bool `json:"is_dubbed"`
- IsClip bool `json:"is_clip"`
- SeoTitle string `json:"seo_title"`
- SeoDescription string `json:"seo_description"`
- SeasonTags []string `json:"season_tags"`
-
- AvailableOffline bool `json:"available_offline"`
- MediaType MediaType `json:"media_type"`
- Slug string `json:"slug"`
-
- Images struct {
- Thumbnail [][]Image `json:"thumbnail"`
- } `json:"images"`
-
- DurationMS int `json:"duration_ms"`
- IsPremiumOnly bool `json:"is_premium_only"`
- ListingID string `json:"listing_id"`
-
- SubtitleLocales []LOCALE `json:"subtitle_locales"`
- Playback string `json:"playback"`
-
- AvailabilityNotes string `json:"availability_notes"`
-
- StreamID string
-}
-
-// HistoryEpisode contains additional information about an episode if the account has watched or started to watch the episode.
-type HistoryEpisode struct {
- *Episode
-
- DatePlayed time.Time `json:"date_played"`
- ParentID string `json:"parent_id"`
- ParentType MediaType `json:"parent_type"`
- Playhead uint `json:"playhead"`
- 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",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- episode := &Episode{
- crunchy: crunchy,
- ID: id,
- }
- if err := decodeMapToStruct(jsonBody, episode); err != nil {
- return nil, err
- }
- if episode.Playback != "" {
- streamHref := jsonBody["__links__"].(map[string]interface{})["streams"].(map[string]interface{})["href"].(string)
- if match := regexp.MustCompile(`(?m)^/cms/v2/\S+videos/(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
- episode.StreamID = match[0][1]
- }
- }
-
- 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
-// this method on the first episode of the season.
-// Will fail if no streams are available, thus use Episode.Available
-// to prevent any misleading errors.
-func (e *Episode) AudioLocale() (LOCALE, error) {
- streams, err := e.Streams()
- if err != nil {
- return "", err
- }
- 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
-}
-
-// 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()
- if err != nil {
- return nil, err
- }
- var foundStream *Stream
- for _, stream := range streams {
- if hardsub && stream.HardsubLocale == subtitle || stream.HardsubLocale == "" && subtitle == "" {
- foundStream = stream
- break
- } else if !hardsub {
- for _, streamSubtitle := range stream.Subtitles {
- if streamSubtitle.Locale == subtitle {
- foundStream = stream
- break
- }
- }
- if foundStream != nil {
- break
- }
- }
- }
-
- if foundStream == nil {
- return nil, fmt.Errorf("no matching stream found")
- }
- formats, err := foundStream.Formats()
- if err != nil {
- return nil, err
- }
- var res *Format
- for _, format := range formats {
- if resolution == "worst" || resolution == "best" {
- if res == nil {
- res = format
- continue
- }
-
- curSplitRes := strings.SplitN(format.Video.Resolution, "x", 2)
- curResX, _ := strconv.Atoi(curSplitRes[0])
- curResY, _ := strconv.Atoi(curSplitRes[1])
-
- resSplitRes := strings.SplitN(res.Video.Resolution, "x", 2)
- resResX, _ := strconv.Atoi(resSplitRes[0])
- resResY, _ := strconv.Atoi(resSplitRes[1])
-
- if resolution == "worst" && curResX+curResY < resResX+resResY {
- res = format
- } else if resolution == "best" && curResX+curResY > resResX+resResY {
- res = format
- }
- }
-
- if format.Video.Resolution == resolution {
- return format, nil
- }
- }
-
- if res != nil {
- return res, nil
- }
-
- return nil, fmt.Errorf("no matching resolution found")
-}
-
-// Streams returns all streams which are available for the episode.
-func (e *Episode) Streams() ([]*Stream, error) {
- if e.children != nil {
- return e.children, nil
- }
-
- streams, err := fromVideoStreams(e.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- e.crunchy.Config.Bucket,
- e.StreamID,
- e.crunchy.Locale,
- e.crunchy.Config.Signature,
- e.crunchy.Config.Policy,
- e.crunchy.Config.KeyPairID))
- if err != nil {
- return nil, err
- }
-
- if e.crunchy.cache {
- e.children = streams
- }
- return streams, nil
-}
diff --git a/error.go b/error.go
deleted file mode 100644
index 9bc2ae6..0000000
--- a/error.go
+++ /dev/null
@@ -1,17 +0,0 @@
-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/format.go b/format.go
deleted file mode 100644
index 6f17bf7..0000000
--- a/format.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package crunchyroll
-
-import (
- "github.com/grafov/m3u8"
-)
-
-type FormatType string
-
-const (
- EPISODE FormatType = "episodes"
- MOVIE = "movies"
-)
-
-// Format contains detailed information about an episode video stream.
-type Format struct {
- crunchy *Crunchyroll
-
- ID string
- // FormatType represents if the format parent is an episode or a movie.
- FormatType FormatType
- Video *m3u8.Variant
- AudioLocale LOCALE
- Hardsub LOCALE
- Subtitles []*Subtitle
-}
-
-// InitVideo initializes the Format.Video completely.
-// The Format.Video.Chunklist pointer is, by default, nil because an additional
-// request must be made to receive its content. The request is not made when
-// initializing a Format struct because it would probably cause an intense overhead
-// since Format.Video.Chunklist is only used sometimes.
-func (f *Format) InitVideo() error {
- if f.Video.Chunklist == nil {
- resp, err := f.crunchy.Client.Get(f.Video.URI)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
- if err != nil {
- return err
- }
- f.Video.Chunklist = playlist.(*m3u8.MediaPlaylist)
- }
- return nil
-}
-
-// Download downloads the Format with the via Downloader specified options.
-func (f *Format) Download(downloader Downloader) error {
- return downloader.download(f)
-}
diff --git a/go.mod b/go.mod
index 5c64ddf..988f612 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
-module github.com/ByteDream/crunchyroll-go/v3
+module github.com/ByteDream/crunchy-cli
go 1.18
require (
+ github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.4.0
)
diff --git a/go.sum b/go.sum
index 34693ca..31a2cd9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY=
+github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..f9c3a90
--- /dev/null
+++ b/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "github.com/ByteDream/crunchy-cli/commands"
+
+func main() {
+ commands.Execute()
+}
diff --git a/movie_listing.go b/movie_listing.go
deleted file mode 100644
index f9bbc21..0000000
--- a/movie_listing.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package crunchyroll
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
-)
-
-// MovieListing contains information about something which is called
-// movie listing. I don't know what this means thb.
-type MovieListing struct {
- crunchy *Crunchyroll
-
- ID string `json:"id"`
-
- Title string `json:"title"`
- Slug string `json:"slug"`
- SlugTitle string `json:"slug_title"`
- Description string `json:"description"`
-
- Images struct {
- Thumbnail [][]Image `json:"thumbnail"`
- } `json:"images"`
-
- DurationMS int `json:"duration_ms"`
- IsPremiumOnly bool `json:"is_premium_only"`
- ListeningID string `json:"listening_id"`
- IsMature bool `json:"is_mature"`
- AvailableOffline bool `json:"available_offline"`
- IsSubbed bool `json:"is_subbed"`
- IsDubbed bool `json:"is_dubbed"`
-
- Playback string `json:"playback"`
- AvailabilityNotes string `json:"availability_notes"`
-}
-
-// MovieListingFromID returns a movie listing by its api id.
-func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movie_listing/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- movieListing := &MovieListing{
- crunchy: crunchy,
- ID: id,
- }
- if err = decodeMapToStruct(jsonBody, movieListing); err != nil {
- return nil, err
- }
-
- return movieListing, nil
-}
-
-// AudioLocale is same as Episode.AudioLocale.
-func (ml *MovieListing) AudioLocale() (LOCALE, error) {
- resp, err := ml.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- ml.crunchy.Config.Bucket,
- ml.ID,
- ml.crunchy.Locale,
- ml.crunchy.Config.Signature,
- ml.crunchy.Config.Policy,
- ml.crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- return LOCALE(jsonBody["audio_locale"].(string)), nil
-}
-
-// Streams returns all streams which are available for the movie listing.
-func (ml *MovieListing) Streams() ([]*Stream, error) {
- return fromVideoStreams(ml.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- ml.crunchy.Config.Bucket,
- ml.ID,
- ml.crunchy.Locale,
- ml.crunchy.Config.Signature,
- ml.crunchy.Config.Policy,
- ml.crunchy.Config.KeyPairID))
-}
diff --git a/news.go b/news.go
deleted file mode 100644
index 074dc0e..0000000
--- a/news.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package crunchyroll
-
-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"`
- Image string `json:"image"`
- Creator string `json:"creator"`
- PublishDate string `json:"publish_date"`
- Description string `json:"description"`
-}
diff --git a/parse.go b/parse.go
deleted file mode 100644
index 392e971..0000000
--- a/parse.go
+++ /dev/null
@@ -1,69 +0,0 @@
-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\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\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[^/]+)(/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\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
deleted file mode 100644
index cbbf7e9..0000000
--- a/search.go
+++ /dev/null
@@ -1,193 +0,0 @@
-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/season.go b/season.go
deleted file mode 100644
index 907122d..0000000
--- a/season.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package crunchyroll
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "regexp"
-)
-
-// Season contains information about an anime season.
-type Season struct {
- crunchy *Crunchyroll
-
- children []*Episode
-
- ID string `json:"id"`
- ChannelID string `json:"channel_id"`
-
- Title string `json:"title"`
- SlugTitle string `json:"slug_title"`
-
- SeriesID string `json:"series_id"`
- SeasonNumber int `json:"season_number"`
-
- IsComplete bool `json:"is_complete"`
-
- Description string `json:"description"`
- Keywords []string `json:"keywords"`
- SeasonTags []string `json:"season_tags"`
- IsMature bool `json:"is_mature"`
- MatureBlocked bool `json:"mature_blocked"`
- IsSubbed bool `json:"is_subbed"`
- IsDubbed bool `json:"is_dubbed"`
- IsSimulcast bool `json:"is_simulcast"`
-
- SeoTitle string `json:"seo_title"`
- SeoDescription string `json:"seo_description"`
-
- AvailabilityNotes string `json:"availability_notes"`
-
- // the locales are always empty, idk why, this may change in the future
- AudioLocales []LOCALE
- SubtitleLocales []LOCALE
-}
-
-// SeasonFromID returns a season by its api id.
-func SeasonFromID(crunchy *Crunchyroll, id string) (*Season, error) {
- resp, err := crunchy.Client.Get(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- season := &Season{
- crunchy: crunchy,
- ID: id,
- }
- if err := decodeMapToStruct(jsonBody, season); err != nil {
- return nil, err
- }
-
- return season, nil
-}
-
-// AudioLocale returns the audio locale of the season.
-// Will fail if no streams are available, thus use Season.Available
-// to prevent any misleading errors.
-func (s *Season) AudioLocale() (LOCALE, error) {
- episodes, err := s.Episodes()
- if err != nil {
- return "", err
- }
- return episodes[0].AudioLocale()
-}
-
-// Available returns if downloadable streams for this season are available.
-func (s *Season) Available() (bool, error) {
- episodes, err := s.Episodes()
- if err != nil {
- return false, err
- }
- return episodes[0].Available(), nil
-}
-
-// Episodes returns all episodes which are available for the season.
-func (s *Season) Episodes() (episodes []*Episode, err error) {
- if s.children != nil {
- return s.children, nil
- }
-
- resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/episodes?season_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- s.crunchy.Config.Bucket,
- s.ID,
- s.crunchy.Locale,
- s.crunchy.Config.Signature,
- s.crunchy.Config.Policy,
- s.crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- for _, item := range jsonBody["items"].([]interface{}) {
- episode := &Episode{
- crunchy: s.crunchy,
- }
- if err = decodeMapToStruct(item, episode); err != nil {
- return nil, err
- }
- if episode.Playback != "" {
- streamHref := item.(map[string]interface{})["__links__"].(map[string]interface{})["streams"].(map[string]interface{})["href"].(string)
- if match := regexp.MustCompile(`(?m)(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
- episode.StreamID = match[0][1]
- } else {
- fmt.Println()
- }
- }
- episodes = append(episodes, episode)
- }
-
- if s.crunchy.cache {
- s.children = episodes
- }
- return
-}
diff --git a/simulcast.go b/simulcast.go
deleted file mode 100644
index e77d2f8..0000000
--- a/simulcast.go
+++ /dev/null
@@ -1,45 +0,0 @@
-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"`
-
- Localization struct {
- Title string `json:"title"`
-
- // appears to be always an empty string.
- Description string `json:"description"`
- } `json:"localization"`
-}
diff --git a/stream.go b/stream.go
deleted file mode 100644
index e5ce54f..0000000
--- a/stream.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package crunchyroll
-
-import (
- "encoding/json"
- "fmt"
- "github.com/grafov/m3u8"
- "net/http"
- "regexp"
-)
-
-// Stream contains information about all available video stream of an episode.
-type Stream struct {
- crunchy *Crunchyroll
-
- children []*Format
-
- HardsubLocale LOCALE
- AudioLocale LOCALE
- Subtitles []*Subtitle
-
- formatType FormatType
- id string
- streamURL string
-}
-
-// StreamsFromID returns a stream by its api id.
-func StreamsFromID(crunchy *Crunchyroll, id string) ([]*Stream, error) {
- return fromVideoStreams(crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID))
-}
-
-// Formats returns all formats which are available for the stream.
-func (s *Stream) Formats() ([]*Format, error) {
- if s.children != nil {
- return s.children, nil
- }
-
- resp, err := s.crunchy.Client.Get(s.streamURL)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
- if err != nil {
- return nil, err
- }
- var formats []*Format
- for _, variant := range playlist.(*m3u8.MasterPlaylist).Variants {
- formats = append(formats, &Format{
- crunchy: s.crunchy,
- ID: s.id,
- FormatType: s.formatType,
- Video: variant,
- AudioLocale: s.AudioLocale,
- Hardsub: s.HardsubLocale,
- Subtitles: s.Subtitles,
- })
- }
-
- if s.crunchy.cache {
- s.children = formats
- }
- return formats, nil
-}
-
-// fromVideoStreams returns all streams which are accessible via the endpoint.
-func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream, err error) {
- resp, err := crunchy.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)
-
- if len(jsonBody) == 0 {
- // this may get thrown when the crunchyroll account is just a normal account and not one with premium
- if !crunchy.Config.Premium {
- return nil, fmt.Errorf("no stream available, this might be the result of using a non-premium account")
- } else {
- return nil, fmt.Errorf("no stream available")
- }
- }
-
- audioLocale := jsonBody["audio_locale"].(string)
-
- var subtitles []*Subtitle
- for _, rawSubtitle := range jsonBody["subtitles"].(map[string]interface{}) {
- subtitle := &Subtitle{
- crunchy: crunchy,
- }
- decodeMapToStruct(rawSubtitle.(map[string]interface{}), subtitle)
- subtitles = append(subtitles, subtitle)
- }
-
- for _, streamData := range jsonBody["streams"].(map[string]interface{})["adaptive_hls"].(map[string]interface{}) {
- streamData := streamData.(map[string]interface{})
-
- hardsubLocale := streamData["hardsub_locale"].(string)
-
- var id string
- var formatType FormatType
- href := jsonBody["__links__"].(map[string]interface{})["resource"].(map[string]interface{})["href"].(string)
- if match := regexp.MustCompile(`(?sm)/(\w+)/(\w+)$`).FindAllStringSubmatch(href, -1); len(match) > 0 {
- formatType = FormatType(match[0][1])
- id = match[0][2]
- }
-
- stream := &Stream{
- crunchy: crunchy,
- HardsubLocale: LOCALE(hardsubLocale),
- formatType: formatType,
- id: id,
- streamURL: streamData["url"].(string),
- AudioLocale: LOCALE(audioLocale),
- Subtitles: subtitles,
- }
-
- streams = append(streams, stream)
- }
-
- return
-}
diff --git a/subtitle.go b/subtitle.go
deleted file mode 100644
index 164b56e..0000000
--- a/subtitle.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package crunchyroll
-
-import (
- "io"
- "net/http"
-)
-
-// Subtitle contains the information about a video subtitle.
-type Subtitle struct {
- crunchy *Crunchyroll
-
- Locale LOCALE `json:"locale"`
- URL string `json:"url"`
- Format string `json:"format"`
-}
-
-// Save writes the subtitle to the given io.Writer.
-func (s Subtitle) Save(writer io.Writer) error {
- req, err := http.NewRequestWithContext(s.crunchy.Context, http.MethodGet, s.URL, nil)
- if err != nil {
- return err
- }
-
- resp, err := s.crunchy.Client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- _, err = io.Copy(writer, resp.Body)
- return err
-}
diff --git a/suggestions.go b/suggestions.go
deleted file mode 100644
index 35d7a9b..0000000
--- a/suggestions.go
+++ /dev/null
@@ -1,82 +0,0 @@
-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/url.go b/url.go
deleted file mode 100644
index 95f3951..0000000
--- a/url.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package crunchyroll
-
-import (
- "fmt"
-)
-
-// ExtractEpisodesFromUrl extracts all episodes from an url.
-// If audio is not empty, the episodes gets filtered after the given locale.
-func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Episode, error) {
- series, episodes, err := c.ParseUrl(url)
- if err != nil {
- return nil, err
- }
-
- var eps []*Episode
- var notAvailableContinue bool
-
- if series != nil {
- seasons, err := series.Seasons()
- if err != nil {
- return nil, err
- }
- for _, season := range seasons {
- if audio != nil {
- if available, err := season.Available(); err != nil {
- return nil, err
- } else if !available {
- notAvailableContinue = true
- continue
- }
-
- locale, err := season.AudioLocale()
- if err != nil {
- return nil, err
- }
-
- var found bool
- for _, l := range audio {
- if locale == l {
- found = true
- break
- }
- }
- if !found {
- continue
- }
- }
- e, err := season.Episodes()
- if err != nil {
- return nil, err
- }
- eps = append(eps, e...)
- }
- } else if episodes != nil {
- if audio == nil {
- return episodes, nil
- }
-
- for _, episode := range episodes {
- // if no episode streams are available, calling episode.AudioLocale
- // will result in an unwanted error
- if !episode.Available() {
- notAvailableContinue = true
- continue
- }
- locale, err := episode.AudioLocale()
- if err != nil {
- return nil, err
- }
- if audio != nil {
- var found bool
- for _, l := range audio {
- if locale == l {
- found = true
- break
- }
- }
- if !found {
- continue
- }
- }
-
- eps = append(eps, episode)
- }
- }
-
- if len(eps) == 0 {
- if notAvailableContinue {
- return nil, fmt.Errorf("could not find any matching episode which is accessable with a non-premium account")
- } else {
- return nil, fmt.Errorf("could not find any matching episode")
- }
- }
-
- return eps, nil
-}
-
-// ParseUrl parses the given url into a series or episode.
-// The returning episode is a slice because non-beta urls have the same episode with different languages.
-func (c *Crunchyroll) ParseUrl(url string) (*Series, []*Episode, error) {
- if seriesId, ok := ParseBetaSeriesURL(url); ok {
- series, err := SeriesFromID(c, seriesId)
- if err != nil {
- return nil, nil, err
- }
- return series, nil, nil
- } else if episodeId, ok := ParseBetaEpisodeURL(url); ok {
- episode, err := EpisodeFromID(c, episodeId)
- if err != nil {
- return nil, nil, err
- }
- return nil, []*Episode{episode}, nil
- } else if seriesName, ok := ParseVideoURL(url); ok {
- video, err := c.FindVideoByName(seriesName)
- if err != nil {
- return nil, nil, err
- }
- return video.(*Series), nil, nil
- } else if seriesName, title, _, _, ok := ParseEpisodeURL(url); ok {
- episodes, err := c.FindEpisodeByName(seriesName, title)
- if err != nil {
- return nil, nil, err
- }
- return nil, episodes, nil
- } else {
- return nil, nil, fmt.Errorf("invalid url %s", url)
- }
-}
diff --git a/utils.go b/utils.go
deleted file mode 100644
index a665040..0000000
--- a/utils.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/url"
- "reflect"
- "strings"
-)
-
-func decodeMapToStruct(m interface{}, s interface{}) error {
- jsonBody, err := json.Marshal(m)
- if err != nil {
- return err
- }
- return json.Unmarshal(jsonBody, s)
-}
-
-func regexGroups(parsed [][]string, subexpNames ...string) map[string]string {
- groups := map[string]string{}
- for _, match := range parsed {
- for i, content := range match {
- if subexpName := subexpNames[i]; subexpName != "" {
- groups[subexpName] = content
- }
- }
- }
- return groups
-}
-
-func encodeStructToQueryValues(s interface{}) (string, error) {
- values := make(url.Values)
- v := reflect.ValueOf(s)
-
- for i := 0; i < v.Type().NumField(); i++ {
-
- // don't include parameters with default or without values in the query to avoid corruption of the API response.
- switch v.Field(i).Kind() {
- case reflect.Slice, reflect.String:
- if v.Field(i).Len() == 0 {
- continue
- }
- case reflect.Bool:
- if !v.Field(i).Bool() {
- continue
- }
- case reflect.Uint:
- if v.Field(i).Uint() == 0 {
- continue
- }
- }
-
- key := v.Type().Field(i).Tag.Get("param")
- var val string
-
- if v.Field(i).Kind() == reflect.Slice {
- var items []string
-
- for _, i := range v.Field(i).Interface().([]string) {
- items = append(items, i)
- }
-
- val = strings.Join(items, ",")
- } else {
- val = fmt.Sprint(v.Field(i).Interface())
- }
-
- values.Add(key, val)
- }
-
- 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/utils/locale.go b/utils/locale.go
deleted file mode 100644
index 85a8650..0000000
--- a/utils/locale.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package utils
-
-import (
- "github.com/ByteDream/crunchyroll-go/v3"
-)
-
-// AllLocales is an array of all available locales.
-var AllLocales = []crunchyroll.LOCALE{
- crunchyroll.JP,
- crunchyroll.US,
- crunchyroll.LA,
- crunchyroll.ES,
- crunchyroll.FR,
- crunchyroll.PT,
- crunchyroll.BR,
- crunchyroll.IT,
- crunchyroll.DE,
- crunchyroll.RU,
- crunchyroll.AR,
-}
-
-// ValidateLocale validates if the given locale actually exist.
-func ValidateLocale(locale crunchyroll.LOCALE) bool {
- for _, l := range AllLocales {
- if l == locale {
- return true
- }
- }
- return false
-}
-
-// LocaleLanguage returns the country by its locale.
-func LocaleLanguage(locale crunchyroll.LOCALE) string {
- switch locale {
- case crunchyroll.JP:
- return "Japanese"
- case crunchyroll.US:
- return "English (US)"
- case crunchyroll.LA:
- return "Spanish (Latin America)"
- case crunchyroll.ES:
- return "Spanish (Spain)"
- case crunchyroll.FR:
- return "French"
- case crunchyroll.PT:
- return "Portuguese (Europe)"
- case crunchyroll.BR:
- return "Portuguese (Brazil)"
- case crunchyroll.IT:
- return "Italian"
- case crunchyroll.DE:
- return "German"
- case crunchyroll.RU:
- return "Russian"
- case crunchyroll.AR:
- return "Arabic"
- default:
- return ""
- }
-}
diff --git a/utils/sort.go b/utils/sort.go
deleted file mode 100644
index 661eea8..0000000
--- a/utils/sort.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package utils
-
-import (
- "github.com/ByteDream/crunchyroll-go/v3"
- "sort"
- "strconv"
- "strings"
- "sync"
-)
-
-// SortEpisodesBySeason sorts the given episodes by their seasons.
-// Note that the same episodes just with different audio locales will cause problems.
-func SortEpisodesBySeason(episodes []*crunchyroll.Episode) [][]*crunchyroll.Episode {
- sortMap := map[string]map[int][]*crunchyroll.Episode{}
-
- for _, episode := range episodes {
- if _, ok := sortMap[episode.SeriesID]; !ok {
- sortMap[episode.SeriesID] = map[int][]*crunchyroll.Episode{}
- }
- if _, ok := sortMap[episode.SeriesID][episode.SeasonNumber]; !ok {
- sortMap[episode.SeriesID][episode.SeasonNumber] = make([]*crunchyroll.Episode, 0)
- }
- sortMap[episode.SeriesID][episode.SeasonNumber] = append(sortMap[episode.SeriesID][episode.SeasonNumber], episode)
- }
-
- var eps [][]*crunchyroll.Episode
- for _, series := range sortMap {
- var keys []int
- for seriesNumber := range series {
- keys = append(keys, seriesNumber)
- }
- sort.Ints(keys)
-
- for _, key := range keys {
- es := series[key]
- if len(es) > 0 {
- sort.Sort(EpisodesByNumber(es))
- eps = append(eps, es)
- }
- }
- }
-
- return eps
-}
-
-// SortEpisodesByAudio sort the given episodes by their audio locale.
-func SortEpisodesByAudio(episodes []*crunchyroll.Episode) (map[crunchyroll.LOCALE][]*crunchyroll.Episode, error) {
- eps := map[crunchyroll.LOCALE][]*crunchyroll.Episode{}
-
- errChan := make(chan error)
-
- var wg sync.WaitGroup
- var lock sync.Mutex
- for _, episode := range episodes {
- if !episode.Available() {
- continue
- }
- episode := episode
- wg.Add(1)
- go func() {
- defer wg.Done()
- audioLocale, err := episode.AudioLocale()
- if err != nil {
- errChan <- err
- return
- }
- lock.Lock()
- defer lock.Unlock()
-
- if _, ok := eps[audioLocale]; !ok {
- eps[audioLocale] = make([]*crunchyroll.Episode, 0)
- }
- eps[audioLocale] = append(eps[audioLocale], episode)
- }()
- }
- go func() {
- wg.Wait()
- errChan <- nil
- }()
-
- if err := <-errChan; err != nil {
- return nil, err
- }
- return eps, nil
-}
-
-// MovieListingsByDuration sorts movie listings by their duration.
-type MovieListingsByDuration []*crunchyroll.MovieListing
-
-func (mlbd MovieListingsByDuration) Len() int {
- return len(mlbd)
-}
-func (mlbd MovieListingsByDuration) Swap(i, j int) {
- mlbd[i], mlbd[j] = mlbd[j], mlbd[i]
-}
-func (mlbd MovieListingsByDuration) Less(i, j int) bool {
- return mlbd[i].DurationMS < mlbd[j].DurationMS
-}
-
-// EpisodesByDuration sorts episodes by their duration.
-type EpisodesByDuration []*crunchyroll.Episode
-
-func (ebd EpisodesByDuration) Len() int {
- return len(ebd)
-}
-func (ebd EpisodesByDuration) Swap(i, j int) {
- ebd[i], ebd[j] = ebd[j], ebd[i]
-}
-func (ebd EpisodesByDuration) Less(i, j int) bool {
- return ebd[i].DurationMS < ebd[j].DurationMS
-}
-
-// EpisodesByNumber sorts episodes after their episode number.
-type EpisodesByNumber []*crunchyroll.Episode
-
-func (ebn EpisodesByNumber) Len() int {
- return len(ebn)
-}
-func (ebn EpisodesByNumber) Swap(i, j int) {
- ebn[i], ebn[j] = ebn[j], ebn[i]
-}
-func (ebn EpisodesByNumber) Less(i, j int) bool {
- return ebn[i].EpisodeNumber < ebn[j].EpisodeNumber
-}
-
-// FormatsByResolution sorts formats after their resolution.
-type FormatsByResolution []*crunchyroll.Format
-
-func (fbr FormatsByResolution) Len() int {
- return len(fbr)
-}
-func (fbr FormatsByResolution) Swap(i, j int) {
- fbr[i], fbr[j] = fbr[j], fbr[i]
-}
-func (fbr FormatsByResolution) Less(i, j int) bool {
- iSplitRes := strings.SplitN(fbr[i].Video.Resolution, "x", 2)
- iResX, _ := strconv.Atoi(iSplitRes[0])
- iResY, _ := strconv.Atoi(iSplitRes[1])
-
- jSplitRes := strings.SplitN(fbr[j].Video.Resolution, "x", 2)
- jResX, _ := strconv.Atoi(jSplitRes[0])
- jResY, _ := strconv.Atoi(jSplitRes[1])
-
- return iResX+iResY < jResX+jResY
-}
-
-// SubtitlesByLocale sorts subtitles after their locale.
-type SubtitlesByLocale []*crunchyroll.Subtitle
-
-func (sbl SubtitlesByLocale) Len() int {
- return len(sbl)
-}
-func (sbl SubtitlesByLocale) Swap(i, j int) {
- sbl[i], sbl[j] = sbl[j], sbl[i]
-}
-func (sbl SubtitlesByLocale) Less(i, j int) bool {
- return LocaleLanguage(sbl[i].Locale) < LocaleLanguage(sbl[j].Locale)
-}
diff --git a/video.go b/video.go
deleted file mode 100644
index 810244f..0000000
--- a/video.go
+++ /dev/null
@@ -1,280 +0,0 @@
-package crunchyroll
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
-)
-
-type video struct {
- ID string `json:"id"`
- ExternalID string `json:"external_id"`
-
- Description string `json:"description"`
- Title string `json:"title"`
- Slug string `json:"slug"`
- SlugTitle string `json:"slug_title"`
-
- Images struct {
- PosterTall [][]Image `json:"poster_tall"`
- PosterWide [][]Image `json:"poster_wide"`
- } `json:"images"`
-}
-
-// Video is the base for Movie and Season.
-type Video interface{}
-
-// Movie contains information about a movie.
-type Movie struct {
- video
- Video
-
- crunchy *Crunchyroll
-
- children []*MovieListing
-
- // not generated when calling MovieFromID.
- MovieListingMetadata struct {
- AvailabilityNotes string `json:"availability_notes"`
- AvailableOffline bool `json:"available_offline"`
- DurationMS int `json:"duration_ms"`
- ExtendedDescription string `json:"extended_description"`
- FirstMovieID string `json:"first_movie_id"`
- IsDubbed bool `json:"is_dubbed"`
- IsMature bool `json:"is_mature"`
- IsPremiumOnly bool `json:"is_premium_only"`
- IsSubbed bool `json:"is_subbed"`
- MatureRatings []string `json:"mature_ratings"`
- MovieReleaseYear int `json:"movie_release_year"`
- SubtitleLocales []LOCALE `json:"subtitle_locales"`
- } `json:"movie_listing_metadata"`
-
- Playback string `json:"playback"`
-
- PromoDescription string `json:"promo_description"`
- PromoTitle string `json:"promo_title"`
- SearchMetadata struct {
- Score float64 `json:"score"`
- }
-}
-
-// MovieFromID returns a movie by its api id.
-func MovieFromID(crunchy *Crunchyroll, id string) (*Movie, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- movieListing := &Movie{
- crunchy: crunchy,
- }
- movieListing.ID = id
- if err = decodeMapToStruct(jsonBody, movieListing); err != nil {
- return nil, err
- }
-
- return movieListing, nil
-}
-
-// MovieListing returns all videos corresponding with the movie.
-func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
- if m.children != nil {
- return m.children, nil
- }
-
- resp, err := m.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- m.crunchy.Config.Bucket,
- m.ID,
- m.crunchy.Locale,
- m.crunchy.Config.Signature,
- m.crunchy.Config.Policy,
- m.crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- for _, item := range jsonBody["items"].([]interface{}) {
- movieListing := &MovieListing{
- crunchy: m.crunchy,
- }
- if err = decodeMapToStruct(item, movieListing); err != nil {
- return nil, err
- }
- movieListings = append(movieListings, movieListing)
- }
-
- if m.crunchy.cache {
- m.children = movieListings
- }
- return movieListings, nil
-}
-
-// Series contains information about an anime series.
-type Series struct {
- video
- Video
-
- crunchy *Crunchyroll
-
- children []*Season
-
- PromoDescription string `json:"promo_description"`
- PromoTitle string `json:"promo_title"`
-
- AvailabilityNotes string `json:"availability_notes"`
- EpisodeCount int `json:"episode_count"`
- ExtendedDescription string `json:"extended_description"`
- IsDubbed bool `json:"is_dubbed"`
- IsMature bool `json:"is_mature"`
- IsSimulcast bool `json:"is_simulcast"`
- IsSubbed bool `json:"is_subbed"`
- MatureBlocked bool `json:"mature_blocked"`
- MatureRatings []string `json:"mature_ratings"`
- SeasonCount int `json:"season_count"`
-
- // not generated when calling SeriesFromID.
- SearchMetadata struct {
- Score float64 `json:"score"`
- }
-}
-
-// SeriesFromID returns a series by its api id.
-func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
- resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- crunchy.Config.Bucket,
- id,
- crunchy.Locale,
- crunchy.Config.Signature,
- crunchy.Config.Policy,
- crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- series := &Series{
- crunchy: crunchy,
- }
- series.ID = id
- if err = decodeMapToStruct(jsonBody, series); err != nil {
- return nil, err
- }
-
- 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 {
- return s.children, nil
- }
-
- resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
- s.crunchy.Config.Bucket,
- s.ID,
- s.crunchy.Locale,
- s.crunchy.Config.Signature,
- s.crunchy.Config.Policy,
- s.crunchy.Config.KeyPairID), http.MethodGet)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- var jsonBody map[string]interface{}
- json.NewDecoder(resp.Body).Decode(&jsonBody)
-
- for _, item := range jsonBody["items"].([]interface{}) {
- season := &Season{
- crunchy: s.crunchy,
- }
- if err = decodeMapToStruct(item, season); err != nil {
- return nil, err
- }
- seasons = append(seasons, season)
- }
-
- if s.crunchy.cache {
- s.children = seasons
- }
- return
-}
diff --git a/wallpaper.go b/wallpaper.go
deleted file mode 100644
index e99bafb..0000000
--- a/wallpaper.go
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index d9ce265..0000000
--- a/watch_history.go
+++ /dev/null
@@ -1,45 +0,0 @@
-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
deleted file mode 100644
index 6fa0bc0..0000000
--- a/watchlist.go
+++ /dev/null
@@ -1,99 +0,0 @@
-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"`
-}
From 781e52059145adeceb24e2dd9b764f80e3407920 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 29 Jun 2022 20:39:38 +0200
Subject: [PATCH 74/87] Update cobra version to 1.5.0
---
go.mod | 2 +-
go.sum | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/go.mod b/go.mod
index 988f612..2c0abc0 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.18
require (
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c
github.com/grafov/m3u8 v0.11.1
- github.com/spf13/cobra v1.4.0
+ github.com/spf13/cobra v1.5.0
)
require (
diff --git a/go.sum b/go.sum
index 31a2cd9..626a2c5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,13 @@
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY=
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
-github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q=
-github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
+github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
+github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
From 303689ecbb1b303dde81856350991eb30d2c3b3f Mon Sep 17 00:00:00 2001
From: bytedream
Date: Thu, 30 Jun 2022 16:08:08 +0200
Subject: [PATCH 75/87] Move and refactor files and some more changes :3
---
Makefile | 8 +-
{commands => cli/commands/archive}/archive.go | 324 ++++--------
cli/commands/archive/compress.go | 136 +++++
.../commands/download}/download.go | 144 +++---
cli/commands/info/info.go | 40 ++
{commands => cli/commands}/logger.go | 59 ++-
cli/commands/login/login.go | 158 ++++++
{commands => cli/commands}/unix.go | 2 +-
{commands => cli/commands/update}/update.go | 35 +-
cli/commands/utils.go | 125 +++++
{commands => cli/commands}/windows.go | 2 +-
cli/root.go | 85 +++
commands/info.go | 40 --
commands/login.go | 206 --------
commands/root.go | 78 ---
commands/utils.go | 485 ------------------
crunchy-cli.1 | 2 +-
go.mod | 2 +-
go.sum | 2 +
main.go | 6 +-
utils/extract.go | 94 ++++
utils/file.go | 49 ++
utils/format.go | 63 +++
utils/http.go | 51 ++
utils/locale.go | 59 +++
utils/logger.go | 12 +
utils/save.go | 177 +++++++
utils/system.go | 7 +
utils/vars.go | 14 +
29 files changed, 1305 insertions(+), 1160 deletions(-)
rename {commands => cli/commands/archive}/archive.go (67%)
create mode 100644 cli/commands/archive/compress.go
rename {commands => cli/commands/download}/download.go (64%)
create mode 100644 cli/commands/info/info.go
rename {commands => cli/commands}/logger.go (82%)
create mode 100644 cli/commands/login/login.go
rename {commands => cli/commands}/unix.go (95%)
rename {commands => cli/commands/update}/update.go (67%)
create mode 100644 cli/commands/utils.go
rename {commands => cli/commands}/windows.go (94%)
create mode 100644 cli/root.go
delete mode 100644 commands/info.go
delete mode 100644 commands/login.go
delete mode 100644 commands/root.go
delete mode 100644 commands/utils.go
create mode 100644 utils/extract.go
create mode 100644 utils/file.go
create mode 100644 utils/format.go
create mode 100644 utils/http.go
create mode 100644 utils/locale.go
create mode 100644 utils/logger.go
create mode 100644 utils/save.go
create mode 100644 utils/system.go
create mode 100644 utils/vars.go
diff --git a/Makefile b/Makefile
index 4266a91..7836af9 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ DESTDIR=
PREFIX=/usr
build:
- go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(BINARY_NAME) .
+ go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(BINARY_NAME) .
clean:
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
@@ -24,8 +24,8 @@ uninstall:
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
release:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
- CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
+ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
strip $(VERSION_BINARY_NAME)_linux
diff --git a/commands/archive.go b/cli/commands/archive/archive.go
similarity index 67%
rename from commands/archive.go
rename to cli/commands/archive/archive.go
index 5ad6900..29ee836 100644
--- a/commands/archive.go
+++ b/cli/commands/archive/archive.go
@@ -1,15 +1,14 @@
-package commands
+package archive
import (
- "archive/tar"
- "archive/zip"
"bufio"
"bytes"
- "compress/gzip"
"context"
"fmt"
+ "github.com/ByteDream/crunchy-cli/cli/commands"
+ "github.com/ByteDream/crunchy-cli/utils"
"github.com/ByteDream/crunchyroll-go/v3"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
+ crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"io"
@@ -23,8 +22,6 @@ import (
"sort"
"strconv"
"strings"
- "sync"
- "time"
)
var (
@@ -42,39 +39,39 @@ var (
archiveGoroutinesFlag int
)
-var archiveCmd = &cobra.Command{
+var Cmd = &cobra.Command{
Use: "archive",
Short: "Stores the given videos with all subtitles and multiple audios in a .mkv file",
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
- out.Debug("Validating arguments")
+ utils.Log.Debug("Validating arguments")
- if !hasFFmpeg() {
+ if !utils.HasFFmpeg() {
return fmt.Errorf("ffmpeg is needed to run this command correctly")
}
- out.Debug("FFmpeg detected")
+ utils.Log.Debug("FFmpeg detected")
if filepath.Ext(archiveOutputFlag) != ".mkv" {
return fmt.Errorf("currently only matroska / .mkv files are supported")
}
for _, locale := range archiveLanguagesFlag {
- if !utils.ValidateLocale(crunchyroll.LOCALE(locale)) {
+ if !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(locale)) {
// if locale is 'all', match all known locales
if locale == "all" {
- archiveLanguagesFlag = allLocalesAsStrings()
+ archiveLanguagesFlag = utils.LocalesAsStrings()
break
}
- return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(allLocalesAsStrings(), ", "))
+ return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(utils.LocalesAsStrings(), ", "))
}
}
- out.Debug("Using following audio locales: %s", strings.Join(archiveLanguagesFlag, ", "))
+ utils.Log.Debug("Using following audio locales: %s", strings.Join(archiveLanguagesFlag, ", "))
var found bool
for _, mode := range []string{"auto", "audio", "video"} {
if archiveMergeFlag == mode {
- out.Debug("Using %s merge behavior", archiveMergeFlag)
+ utils.Log.Debug("Using %s merge behavior", archiveMergeFlag)
found = true
break
}
@@ -87,7 +84,7 @@ var archiveCmd = &cobra.Command{
found = false
for _, algo := range []string{".tar", ".tar.gz", ".tgz", ".zip"} {
if strings.HasSuffix(archiveCompressFlag, algo) {
- out.Debug("Using %s compression", algo)
+ utils.Log.Debug("Using %s compression", algo)
found = true
break
}
@@ -100,8 +97,8 @@ var archiveCmd = &cobra.Command{
switch archiveResolutionFlag {
case "1080p", "720p", "480p", "360p":
- intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84)
- archiveResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(downloadResolutionFlag, "p"))
+ intRes, _ := strconv.ParseFloat(strings.TrimSuffix(archiveResolutionFlag, "p"), 84)
+ archiveResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(archiveResolutionFlag, "p"))
case "240p":
// 240p would round up to 427x240 if used in the case statement above, so it has to be handled separately
archiveResolutionFlag = "428x240"
@@ -109,31 +106,33 @@ var archiveCmd = &cobra.Command{
default:
return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag)
}
- out.Debug("Using resolution '%s'", archiveResolutionFlag)
+ utils.Log.Debug("Using resolution '%s'", archiveResolutionFlag)
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
- loadCrunchy()
+ if err := commands.LoadCrunchy(); err != nil {
+ return err
+ }
return archive(args)
},
}
func init() {
- archiveCmd.Flags().StringSliceVarP(&archiveLanguagesFlag,
+ Cmd.Flags().StringSliceVarP(&archiveLanguagesFlag,
"language",
"l",
- []string{string(systemLocale(false)), string(crunchyroll.JP)},
+ []string{string(utils.SystemLocale(false)), string(crunchyroll.JP)},
"Audio locale which should be downloaded. Can be used multiple times")
cwd, _ := os.Getwd()
- archiveCmd.Flags().StringVarP(&archiveDirectoryFlag,
+ Cmd.Flags().StringVarP(&archiveDirectoryFlag,
"directory",
"d",
cwd,
"The directory to store the files into")
- archiveCmd.Flags().StringVarP(&archiveOutputFlag,
+ Cmd.Flags().StringVarP(&archiveOutputFlag,
"output",
"o",
"{title}.mkv",
@@ -148,13 +147,13 @@ func init() {
"\t{audio} » Audio locale of the video\n"+
"\t{subtitle} » Subtitle locale of the video")
- archiveCmd.Flags().StringVarP(&archiveMergeFlag,
+ Cmd.Flags().StringVarP(&archiveMergeFlag,
"merge",
"m",
"auto",
"Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'")
- archiveCmd.Flags().StringVarP(&archiveCompressFlag,
+ Cmd.Flags().StringVarP(&archiveCompressFlag,
"compress",
"c",
"",
@@ -162,7 +161,7 @@ func init() {
"This flag sets the name of the compressed output file. The file ending specifies the compression algorithm. "+
"The following algorithms are supported: gzip, tar, zip")
- archiveCmd.Flags().StringVarP(&archiveResolutionFlag,
+ Cmd.Flags().StringVarP(&archiveResolutionFlag,
"resolution",
"r",
"best",
@@ -171,51 +170,49 @@ func init() {
"\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+
"\tAvailable common-use words: best (best available resolution), worst (worst available resolution)")
- archiveCmd.Flags().IntVarP(&archiveGoroutinesFlag,
+ Cmd.Flags().IntVarP(&archiveGoroutinesFlag,
"goroutines",
"g",
runtime.NumCPU(),
"Number of parallel segment downloads")
-
- rootCmd.AddCommand(archiveCmd)
}
func archive(urls []string) error {
for i, url := range urls {
- out.SetProgress("Parsing url %d", i+1)
+ utils.Log.SetProcess("Parsing url %d", i+1)
episodes, err := archiveExtractEpisodes(url)
if err != nil {
- out.StopProgress("Failed to parse url %d", i+1)
- if crunchy.Config.Premium {
- out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
+ utils.Log.StopProcess("Failed to parse url %d", i+1)
+ if utils.Crunchy.Config.Premium {
+ utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
}
return err
}
- out.StopProgress("Parsed url %d", i+1)
+ utils.Log.StopProcess("Parsed url %d", i+1)
var compressFile *os.File
- var c compress
+ var c Compress
if archiveCompressFlag != "" {
- compressFile, err = os.Create(generateFilename(archiveCompressFlag, ""))
+ compressFile, err = os.Create(utils.GenerateFilename(archiveCompressFlag, ""))
if err != nil {
return fmt.Errorf("failed to create archive file: %v", err)
}
if strings.HasSuffix(archiveCompressFlag, ".tar") {
- c = newTarCompress(compressFile)
+ c = NewTarCompress(compressFile)
} else if strings.HasSuffix(archiveCompressFlag, ".tar.gz") || strings.HasSuffix(archiveCompressFlag, ".tgz") {
- c = newGzipCompress(compressFile)
+ c = NewGzipCompress(compressFile)
} else if strings.HasSuffix(archiveCompressFlag, ".zip") {
- c = newZipCompress(compressFile)
+ c = NewZipCompress(compressFile)
}
}
for _, season := range episodes {
- out.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
+ utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
for j, info := range season {
- out.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
+ utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
j+1,
info.Title,
info.Resolution,
@@ -224,26 +221,26 @@ func archive(urls []string) error {
info.EpisodeNumber)
}
}
- out.Empty()
+ utils.Log.Empty()
for j, season := range episodes {
for k, info := range season {
var filename string
var writeCloser io.WriteCloser
if c != nil {
- filename = info.Format(archiveOutputFlag)
+ filename = info.FormatString(archiveOutputFlag)
writeCloser, err = c.NewFile(info)
if err != nil {
return fmt.Errorf("failed to pre generate new archive file: %v", err)
}
} else {
- dir := info.Format(archiveDirectoryFlag)
+ dir := info.FormatString(archiveDirectoryFlag)
if _, err = os.Stat(dir); os.IsNotExist(err) {
if err = os.MkdirAll(dir, 0777); err != nil {
return fmt.Errorf("error while creating directory: %v", err)
}
}
- filename = generateFilename(info.Format(archiveOutputFlag), dir)
+ filename = utils.GenerateFilename(info.FormatString(archiveOutputFlag), dir)
writeCloser, err = os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create new file: %v", err)
@@ -264,7 +261,7 @@ func archive(urls []string) error {
writeCloser.Close()
if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 {
- out.Empty()
+ utils.Log.Empty()
}
}
}
@@ -278,8 +275,8 @@ func archive(urls []string) error {
return nil
}
-func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename string) error {
- out.Debug("Entering season %d, episode %d with %d additional formats", info.SeasonNumber, info.EpisodeNumber, len(info.additionalFormats))
+func archiveInfo(info utils.FormatInformation, writeCloser io.WriteCloser, filename string) error {
+ utils.Log.Debug("Entering season %d, episode %d with %d additional formats", info.SeasonNumber, info.EpisodeNumber, len(info.AdditionalFormats))
dp, err := createArchiveProgress(info)
if err != nil {
@@ -307,7 +304,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
return nil
}
- if out.IsDev() {
+ if utils.Log.IsDev() {
dp.UpdateMessage(fmt.Sprintf("Downloading %d/%d (%.2f%%) » %s", current, total, float32(current)/float32(total)*100, segment.URI), false)
} else {
dp.Update()
@@ -325,8 +322,8 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
select {
case <-sig:
signal.Stop(sig)
- out.Exit("Exiting... (may take a few seconds)")
- out.Exit("To force exit press ctrl+c (again)")
+ utils.Log.Err("Exiting... (may take a few seconds)")
+ utils.Log.Err("To force exit press ctrl+c (again)")
cancel()
// os.Exit(1) is not called since an immediate exit after the cancel function does not let
// the download process enough time to stop gratefully. A result of this is that the temporary
@@ -335,15 +332,15 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
// this is just here to end the goroutine and prevent it from running forever without a reason
}
}()
- out.Debug("Set up signal catcher")
+ utils.Log.Debug("Set up signal catcher")
var additionalDownloaderOpts []string
var mergeMessage string
switch archiveMergeFlag {
case "auto":
additionalDownloaderOpts = []string{"-vn"}
- for _, format := range info.additionalFormats {
- if format.Video.Bandwidth != info.format.Video.Bandwidth {
+ for _, format := range info.AdditionalFormats {
+ if format.Video.Bandwidth != info.Format.Video.Bandwidth {
// revoke the changed FFmpegOpts above
additionalDownloaderOpts = []string{}
break
@@ -361,12 +358,12 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
mergeMessage = "merging video for additional formats"
}
- out.Info("Downloading episode `%s` to `%s` (%s)", info.Title, filepath.Base(filename), mergeMessage)
- out.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber)
- out.Info("\tAudio: %s", info.Audio)
- out.Info("\tSubtitle: %s", info.Subtitle)
- out.Info("\tResolution: %spx", info.Resolution)
- out.Info("\tFPS: %.2f", info.FPS)
+ utils.Log.Info("Downloading episode `%s` to `%s` (%s)", info.Title, filepath.Base(filename), mergeMessage)
+ utils.Log.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber)
+ utils.Log.Info("\tAudio: %s", info.Audio)
+ utils.Log.Info("\tSubtitle: %s", info.Subtitle)
+ utils.Log.Info("\tResolution: %spx", info.Resolution)
+ utils.Log.Info("\tFPS: %.2f", info.FPS)
var videoFiles, audioFiles, subtitleFiles []string
defer func() {
@@ -376,7 +373,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
}()
var f []string
- if f, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.format); err != nil {
+ if f, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.Format); err != nil {
if err != ctx.Err() {
return fmt.Errorf("error while downloading: %v", err)
}
@@ -387,29 +384,29 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
if len(additionalDownloaderOpts) == 0 {
var videos []string
downloader.FFmpegOpts = additionalDownloaderOpts
- if videos, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.additionalFormats...); err != nil {
+ if videos, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.AdditionalFormats...); err != nil {
return fmt.Errorf("error while downloading additional videos: %v", err)
}
downloader.FFmpegOpts = []string{}
videoFiles = append(videoFiles, videos...)
} else {
var audios []string
- if audios, err = archiveDownloadVideos(downloader, filepath.Base(filename), false, info.additionalFormats...); err != nil {
+ if audios, err = archiveDownloadVideos(downloader, filepath.Base(filename), false, info.AdditionalFormats...); err != nil {
return fmt.Errorf("error while downloading additional videos: %v", err)
}
audioFiles = append(audioFiles, audios...)
}
- sort.Sort(utils.SubtitlesByLocale(info.format.Subtitles))
+ sort.Sort(crunchyUtils.SubtitlesByLocale(info.Format.Subtitles))
sortSubtitles, _ := strconv.ParseBool(os.Getenv("SORT_SUBTITLES"))
if sortSubtitles && len(archiveLanguagesFlag) > 0 {
// this sort the subtitle locales after the languages which were specified
// with the `archiveLanguagesFlag` flag
for _, language := range archiveLanguagesFlag {
- for i, subtitle := range info.format.Subtitles {
+ for i, subtitle := range info.Format.Subtitles {
if subtitle.Locale == crunchyroll.LOCALE(language) {
- info.format.Subtitles = append([]*crunchyroll.Subtitle{subtitle}, append(info.format.Subtitles[:i], info.format.Subtitles[i+1:]...)...)
+ info.Format.Subtitles = append([]*crunchyroll.Subtitle{subtitle}, append(info.Format.Subtitles[:i], info.Format.Subtitles[i+1:]...)...)
break
}
}
@@ -417,7 +414,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
}
var subtitles []string
- if subtitles, err = archiveDownloadSubtitles(filepath.Base(filename), info.format.Subtitles...); err != nil {
+ if subtitles, err = archiveDownloadSubtitles(filepath.Base(filename), info.Format.Subtitles...); err != nil {
return fmt.Errorf("error while downloading subtitles: %v", err)
}
subtitleFiles = append(subtitleFiles, subtitles...)
@@ -429,22 +426,22 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st
dp.UpdateMessage("Download finished", false)
signal.Stop(sig)
- out.Debug("Stopped signal catcher")
+ utils.Log.Debug("Stopped signal catcher")
- out.Empty()
+ utils.Log.Empty()
return nil
}
-func createArchiveProgress(info formatInformation) (*downloadProgress, error) {
+func createArchiveProgress(info utils.FormatInformation) (*commands.DownloadProgress, error) {
var progressCount int
- if err := info.format.InitVideo(); err != nil {
+ if err := info.Format.InitVideo(); err != nil {
return nil, fmt.Errorf("error while initializing a video: %v", err)
}
// + number of segments a video has +1 is for merging
- progressCount += int(info.format.Video.Chunklist.Count()) + 1
- for _, f := range info.additionalFormats {
- if f == info.format {
+ progressCount += int(info.Format.Video.Chunklist.Count()) + 1
+ for _, f := range info.AdditionalFormats {
+ if f == info.Format {
continue
}
@@ -455,16 +452,16 @@ func createArchiveProgress(info formatInformation) (*downloadProgress, error) {
progressCount += int(f.Video.Chunklist.Count()) + 1
}
- dp := &downloadProgress{
- Prefix: out.InfoLog.Prefix(),
+ dp := &commands.DownloadProgress{
+ Prefix: utils.Log.(*commands.Logger).InfoLog.Prefix(),
Message: "Downloading video",
// number of segments a video +1 is for the success message
Total: progressCount + 1,
- Dev: out.IsDev(),
- Quiet: out.IsQuiet(),
+ Dev: utils.Log.IsDev(),
+ Quiet: utils.Log.(*commands.Logger).IsQuiet(),
}
- if out.IsDev() {
- dp.Prefix = out.DebugLog.Prefix()
+ if utils.Log.IsDev() {
+ dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix()
}
return dp, nil
@@ -497,7 +494,7 @@ func archiveDownloadVideos(downloader crunchyroll.Downloader, filename string, v
}
f.Close()
- out.Debug("Downloaded '%s' video", format.AudioLocale)
+ utils.Log.Debug("Downloaded '%s' video", format.AudioLocale)
}
return files, nil
@@ -522,7 +519,7 @@ func archiveDownloadSubtitles(filename string, subtitles ...*crunchyroll.Subtitl
}
f.Close()
- out.Debug("Downloaded '%s' subtitles", subtitle.Locale)
+ utils.Log.Debug("Downloaded '%s' subtitles", subtitle.Locale)
}
return files, nil
@@ -537,9 +534,9 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
maps = append(maps, "-map", strconv.Itoa(i))
locale := crunchyroll.LOCALE(re.FindStringSubmatch(video)[1])
metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("language=%s", locale))
- metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale)))
+ metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale)))
metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", locale))
- metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale)))
+ metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale)))
}
for i, audio := range audioFiles {
@@ -547,7 +544,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
maps = append(maps, "-map", strconv.Itoa(i+len(videoFiles)))
locale := crunchyroll.LOCALE(re.FindStringSubmatch(audio)[1])
metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", locale))
- metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale)))
+ metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale)))
}
for i, subtitle := range subtitleFiles {
@@ -555,7 +552,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
maps = append(maps, "-map", strconv.Itoa(i+len(videoFiles)+len(audioFiles)))
locale := crunchyroll.LOCALE(re.FindStringSubmatch(subtitle)[1])
metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("language=%s", locale))
- metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale)))
+ metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale)))
}
commandOptions := []string{"-y"}
@@ -577,7 +574,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
commandOptions = append(commandOptions, "-disposition:s:0", "0", "-c", "copy", "-f", "matroska", file.Name())
// just a little nicer debug output to copy and paste the ffmpeg for debug reasons
- if out.IsDev() {
+ if utils.Log.IsDev() {
var debugOptions []string
for _, option := range commandOptions {
@@ -591,7 +588,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
debugOptions = append(debugOptions, option)
}
}
- out.Debug("FFmpeg merge command: ffmpeg %s", strings.Join(debugOptions, " "))
+ utils.Log.Debug("FFmpeg merge command: ffmpeg %s", strings.Join(debugOptions, " "))
}
var errBuf bytes.Buffer
@@ -611,7 +608,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s
return err
}
-func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
+func archiveExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
var hasJapanese bool
languagesAsLocale := []crunchyroll.LOCALE{crunchyroll.JP}
for _, language := range archiveLanguagesFlag {
@@ -623,7 +620,7 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
}
}
- episodes, err := extractEpisodes(url, languagesAsLocale...)
+ episodes, err := utils.ExtractEpisodes(url, languagesAsLocale...)
if err != nil {
return nil, err
}
@@ -634,9 +631,9 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
for i, eps := range episodes {
if len(eps) == 0 {
- out.SetProgress("%s has no matching episodes", languagesAsLocale[i])
+ utils.Log.SetProcess("%s has no matching episodes", languagesAsLocale[i])
} else if len(episodes[0]) > len(eps) {
- out.SetProgress("%s has %d less episodes than existing in japanese (%d)", languagesAsLocale[i], len(episodes[0])-len(eps), len(episodes[0]))
+ utils.Log.SetProcess("%s has %d less episodes than existing in japanese (%d)", languagesAsLocale[i], len(episodes[0])-len(eps), len(episodes[0]))
}
}
@@ -644,11 +641,11 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
episodes = episodes[1:]
}
- eps := make(map[int]map[int]*formatInformation)
+ eps := make(map[int]map[int]*utils.FormatInformation)
for _, lang := range episodes {
- for _, season := range utils.SortEpisodesBySeason(lang) {
+ for _, season := range crunchyUtils.SortEpisodesBySeason(lang) {
if _, ok := eps[season[0].SeasonNumber]; !ok {
- eps[season[0].SeasonNumber] = map[int]*formatInformation{}
+ eps[season[0].SeasonNumber] = map[int]*utils.FormatInformation{}
}
for _, episode := range season {
format, err := episode.GetFormat(archiveResolutionFlag, "", false)
@@ -657,9 +654,9 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
}
if _, ok := eps[episode.SeasonNumber][episode.EpisodeNumber]; !ok {
- eps[episode.SeasonNumber][episode.EpisodeNumber] = &formatInformation{
- format: format,
- additionalFormats: make([]*crunchyroll.Format, 0),
+ eps[episode.SeasonNumber][episode.EpisodeNumber] = &utils.FormatInformation{
+ Format: format,
+ AdditionalFormats: make([]*crunchyroll.Format, 0),
Title: episode.Title,
SeriesName: episode.SeriesTitle,
@@ -671,15 +668,15 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
Audio: format.AudioLocale,
}
} else {
- eps[episode.SeasonNumber][episode.EpisodeNumber].additionalFormats = append(eps[episode.SeasonNumber][episode.EpisodeNumber].additionalFormats, format)
+ eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats = append(eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats, format)
}
}
}
}
- var infoFormat [][]formatInformation
+ var infoFormat [][]utils.FormatInformation
for _, e := range eps {
- var tmpFormatInfo []formatInformation
+ var tmpFormatInfo []utils.FormatInformation
var keys []int
for episodeNumber := range e {
@@ -696,124 +693,3 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) {
return infoFormat, nil
}
-
-type compress interface {
- io.Closer
-
- NewFile(information formatInformation) (io.WriteCloser, error)
-}
-
-func newGzipCompress(file *os.File) *tarCompress {
- gw := gzip.NewWriter(file)
- return &tarCompress{
- parent: gw,
- dst: tar.NewWriter(gw),
- }
-}
-
-func newTarCompress(file *os.File) *tarCompress {
- return &tarCompress{
- dst: tar.NewWriter(file),
- }
-}
-
-type tarCompress struct {
- compress
-
- wg sync.WaitGroup
-
- parent *gzip.Writer
- dst *tar.Writer
-}
-
-func (tc *tarCompress) Close() error {
- // we have to wait here in case the actual content isn't copied completely into the
- // writer yet
- tc.wg.Wait()
-
- var err, err2 error
- if tc.parent != nil {
- err2 = tc.parent.Close()
- }
- err = tc.dst.Close()
-
- if err != nil && err2 != nil {
- // best way to show double errors at once that I've found
- return fmt.Errorf("%v\n%v", err, err2)
- } else if err == nil && err2 != nil {
- err = err2
- }
-
- return err
-}
-
-func (tc *tarCompress) NewFile(information formatInformation) (io.WriteCloser, error) {
- rp, wp := io.Pipe()
- go func() {
- tc.wg.Add(1)
- defer tc.wg.Done()
- var buf bytes.Buffer
- io.Copy(&buf, rp)
-
- header := &tar.Header{
- Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title),
- ModTime: time.Now(),
- Mode: 0644,
- Typeflag: tar.TypeReg,
- // fun fact: I did not set the size for quiet some time because I thought that it isn't
- // required. well because of this I debugged this part for multiple hours because without
- // proper size information only a tiny amount gets copied into the tar (or zip) writer.
- // this is also the reason why the file content is completely copied into a buffer before
- // writing it to the writer. I could bypass this and save some memory but this requires
- // some rewriting and im nearly at the (planned) finish for version 2 so nah in the future
- // maybe
- Size: int64(buf.Len()),
- }
- tc.dst.WriteHeader(header)
- io.Copy(tc.dst, &buf)
- }()
- return wp, nil
-}
-
-func newZipCompress(file *os.File) *zipCompress {
- return &zipCompress{
- dst: zip.NewWriter(file),
- }
-}
-
-type zipCompress struct {
- compress
-
- wg sync.WaitGroup
-
- dst *zip.Writer
-}
-
-func (zc *zipCompress) Close() error {
- zc.wg.Wait()
- return zc.dst.Close()
-}
-
-func (zc *zipCompress) NewFile(information formatInformation) (io.WriteCloser, error) {
- rp, wp := io.Pipe()
- go func() {
- zc.wg.Add(1)
- defer zc.wg.Done()
-
- var buf bytes.Buffer
- io.Copy(&buf, rp)
-
- header := &zip.FileHeader{
- Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title),
- Modified: time.Now(),
- Method: zip.Deflate,
- UncompressedSize64: uint64(buf.Len()),
- }
- header.SetMode(0644)
-
- hw, _ := zc.dst.CreateHeader(header)
- io.Copy(hw, &buf)
- }()
-
- return wp, nil
-}
diff --git a/cli/commands/archive/compress.go b/cli/commands/archive/compress.go
new file mode 100644
index 0000000..d6aa492
--- /dev/null
+++ b/cli/commands/archive/compress.go
@@ -0,0 +1,136 @@
+package archive
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "github.com/ByteDream/crunchy-cli/utils"
+ "io"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type Compress interface {
+ io.Closer
+
+ NewFile(information utils.FormatInformation) (io.WriteCloser, error)
+}
+
+func NewGzipCompress(file *os.File) *TarCompress {
+ gw := gzip.NewWriter(file)
+ return &TarCompress{
+ parent: gw,
+ dst: tar.NewWriter(gw),
+ }
+}
+
+func NewTarCompress(file *os.File) *TarCompress {
+ return &TarCompress{
+ dst: tar.NewWriter(file),
+ }
+}
+
+type TarCompress struct {
+ Compress
+
+ wg sync.WaitGroup
+
+ parent *gzip.Writer
+ dst *tar.Writer
+}
+
+func (tc *TarCompress) Close() error {
+ // we have to wait here in case the actual content isn't copied completely into the
+ // writer yet
+ tc.wg.Wait()
+
+ var err, err2 error
+ if tc.parent != nil {
+ err2 = tc.parent.Close()
+ }
+ err = tc.dst.Close()
+
+ if err != nil && err2 != nil {
+ // best way to show double errors at once that I've found
+ return fmt.Errorf("%v\n%v", err, err2)
+ } else if err == nil && err2 != nil {
+ err = err2
+ }
+
+ return err
+}
+
+func (tc *TarCompress) NewFile(information utils.FormatInformation) (io.WriteCloser, error) {
+ rp, wp := io.Pipe()
+ go func() {
+ tc.wg.Add(1)
+ defer tc.wg.Done()
+ var buf bytes.Buffer
+ io.Copy(&buf, rp)
+
+ header := &tar.Header{
+ Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title),
+ ModTime: time.Now(),
+ Mode: 0644,
+ Typeflag: tar.TypeReg,
+ // fun fact: I did not set the size for quiet some time because I thought that it isn't
+ // required. well because of this I debugged this part for multiple hours because without
+ // proper size information only a tiny amount gets copied into the tar (or zip) writer.
+ // this is also the reason why the file content is completely copied into a buffer before
+ // writing it to the writer. I could bypass this and save some memory but this requires
+ // some rewriting and im nearly at the (planned) finish for version 2 so nah in the future
+ // maybe
+ Size: int64(buf.Len()),
+ }
+ tc.dst.WriteHeader(header)
+ io.Copy(tc.dst, &buf)
+ }()
+ return wp, nil
+}
+
+func NewZipCompress(file *os.File) *ZipCompress {
+ return &ZipCompress{
+ dst: zip.NewWriter(file),
+ }
+}
+
+type ZipCompress struct {
+ Compress
+
+ wg sync.WaitGroup
+
+ dst *zip.Writer
+}
+
+func (zc *ZipCompress) Close() error {
+ zc.wg.Wait()
+ return zc.dst.Close()
+}
+
+func (zc *ZipCompress) NewFile(information utils.FormatInformation) (io.WriteCloser, error) {
+ rp, wp := io.Pipe()
+ go func() {
+ zc.wg.Add(1)
+ defer zc.wg.Done()
+
+ var buf bytes.Buffer
+ io.Copy(&buf, rp)
+
+ header := &zip.FileHeader{
+ Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title),
+ Modified: time.Now(),
+ Method: zip.Deflate,
+ UncompressedSize64: uint64(buf.Len()),
+ }
+ header.SetMode(0644)
+
+ hw, _ := zc.dst.CreateHeader(header)
+ io.Copy(hw, &buf)
+ }()
+
+ return wp, nil
+}
diff --git a/commands/download.go b/cli/commands/download/download.go
similarity index 64%
rename from commands/download.go
rename to cli/commands/download/download.go
index 82b68bf..4e0ef66 100644
--- a/commands/download.go
+++ b/cli/commands/download/download.go
@@ -1,10 +1,12 @@
-package commands
+package download
import (
"context"
"fmt"
+ "github.com/ByteDream/crunchy-cli/cli/commands"
+ "github.com/ByteDream/crunchy-cli/utils"
"github.com/ByteDream/crunchyroll-go/v3"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
+ crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"math"
@@ -29,29 +31,29 @@ var (
downloadGoroutinesFlag int
)
-var downloadCmd = &cobra.Command{
+var Cmd = &cobra.Command{
Use: "download",
Short: "Download a video",
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
- out.Debug("Validating arguments")
+ utils.Log.Debug("Validating arguments")
if filepath.Ext(downloadOutputFlag) != ".ts" {
- if !hasFFmpeg() {
+ if !utils.HasFFmpeg() {
return fmt.Errorf("the file ending for the output file (%s) is not `.ts`. "+
"Install ffmpeg (https://ffmpeg.org/download.html) to use other media file endings (e.g. `.mp4`)", downloadOutputFlag)
} else {
- out.Debug("Custom file ending '%s' (ffmpeg is installed)", filepath.Ext(downloadOutputFlag))
+ utils.Log.Debug("Custom file ending '%s' (ffmpeg is installed)", filepath.Ext(downloadOutputFlag))
}
}
- if !utils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) {
- return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(allLocalesAsStrings(), ", "))
- } else if downloadSubtitleFlag != "" && !utils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
- return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(allLocalesAsStrings(), ", "))
+ if !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) {
+ return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(utils.LocalesAsStrings(), ", "))
+ } else if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
+ return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(utils.LocalesAsStrings(), ", "))
}
- out.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
+ utils.Log.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
switch downloadResolutionFlag {
case "1080p", "720p", "480p", "360p":
@@ -64,35 +66,37 @@ var downloadCmd = &cobra.Command{
default:
return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag)
}
- out.Debug("Using resolution '%s'", downloadResolutionFlag)
+ utils.Log.Debug("Using resolution '%s'", downloadResolutionFlag)
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
- loadCrunchy()
+ if err := commands.LoadCrunchy(); err != nil {
+ return err
+ }
return download(args)
},
}
func init() {
- downloadCmd.Flags().StringVarP(&downloadAudioFlag, "audio",
+ Cmd.Flags().StringVarP(&downloadAudioFlag, "audio",
"a",
- string(systemLocale(false)),
- "The locale of the audio. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
- downloadCmd.Flags().StringVarP(&downloadSubtitleFlag,
+ string(utils.SystemLocale(false)),
+ "The locale of the audio. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", "))
+ Cmd.Flags().StringVarP(&downloadSubtitleFlag,
"subtitle",
"s",
"",
- "The locale of the subtitle. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
+ "The locale of the subtitle. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", "))
cwd, _ := os.Getwd()
- downloadCmd.Flags().StringVarP(&downloadDirectoryFlag,
+ Cmd.Flags().StringVarP(&downloadDirectoryFlag,
"directory",
"d",
cwd,
"The directory to download the file(s) into")
- downloadCmd.Flags().StringVarP(&downloadOutputFlag,
+ Cmd.Flags().StringVarP(&downloadOutputFlag,
"output",
"o",
"{title}.ts",
@@ -108,7 +112,7 @@ func init() {
"\t{audio} » Audio locale of the video\n"+
"\t{subtitle} » Subtitle locale of the video")
- downloadCmd.Flags().StringVarP(&downloadResolutionFlag,
+ Cmd.Flags().StringVarP(&downloadResolutionFlag,
"resolution",
"r",
"best",
@@ -117,34 +121,32 @@ func init() {
"\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+
"\tAvailable common-use words: best (best available resolution), worst (worst available resolution)")
- downloadCmd.Flags().IntVarP(&downloadGoroutinesFlag,
+ Cmd.Flags().IntVarP(&downloadGoroutinesFlag,
"goroutines",
"g",
runtime.NumCPU(),
"Sets how many parallel segment downloads should be used")
-
- rootCmd.AddCommand(downloadCmd)
}
func download(urls []string) error {
for i, url := range urls {
- out.SetProgress("Parsing url %d", i+1)
+ utils.Log.SetProcess("Parsing url %d", i+1)
episodes, err := downloadExtractEpisodes(url)
if err != nil {
- out.StopProgress("Failed to parse url %d", i+1)
- if crunchy.Config.Premium {
- out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
+ utils.Log.StopProcess("Failed to parse url %d", i+1)
+ if utils.Crunchy.Config.Premium {
+ utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
}
return err
}
- out.StopProgress("Parsed url %d", i+1)
+ utils.Log.StopProcess("Parsed url %d", i+1)
for _, season := range episodes {
- out.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
+ utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
for j, info := range season {
- out.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
+ utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
j+1,
info.Title,
info.Resolution,
@@ -153,17 +155,17 @@ func download(urls []string) error {
info.EpisodeNumber)
}
}
- out.Empty()
+ utils.Log.Empty()
for j, season := range episodes {
for k, info := range season {
- dir := info.Format(downloadDirectoryFlag)
+ dir := info.FormatString(downloadDirectoryFlag)
if _, err = os.Stat(dir); os.IsNotExist(err) {
if err = os.MkdirAll(dir, 0777); err != nil {
return fmt.Errorf("error while creating directory: %v", err)
}
}
- file, err := os.Create(generateFilename(info.Format(downloadOutputFlag), dir))
+ file, err := os.Create(utils.GenerateFilename(info.FormatString(downloadOutputFlag), dir))
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
@@ -176,7 +178,7 @@ func download(urls []string) error {
file.Close()
if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 {
- out.Empty()
+ utils.Log.Empty()
}
}
}
@@ -184,23 +186,23 @@ func download(urls []string) error {
return nil
}
-func downloadInfo(info formatInformation, file *os.File) error {
- out.Debug("Entering season %d, episode %d", info.SeasonNumber, info.EpisodeNumber)
+func downloadInfo(info utils.FormatInformation, file *os.File) error {
+ utils.Log.Debug("Entering season %d, episode %d", info.SeasonNumber, info.EpisodeNumber)
- if err := info.format.InitVideo(); err != nil {
+ if err := info.Format.InitVideo(); err != nil {
return fmt.Errorf("error while initializing the video: %v", err)
}
- dp := &downloadProgress{
- Prefix: out.InfoLog.Prefix(),
+ dp := &commands.DownloadProgress{
+ Prefix: utils.Log.(*commands.Logger).InfoLog.Prefix(),
Message: "Downloading video",
// number of segments a video has +2 is for merging and the success message
- Total: int(info.format.Video.Chunklist.Count()) + 2,
- Dev: out.IsDev(),
- Quiet: out.IsQuiet(),
+ Total: int(info.Format.Video.Chunklist.Count()) + 2,
+ Dev: utils.Log.IsDev(),
+ Quiet: utils.Log.(*commands.Logger).IsQuiet(),
}
- if out.IsDev() {
- dp.Prefix = out.DebugLog.Prefix()
+ if utils.Log.IsDev() {
+ dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix()
}
defer func() {
if dp.Total != dp.Current {
@@ -217,7 +219,7 @@ func downloadInfo(info formatInformation, file *os.File) error {
return nil
}
- if out.IsDev() {
+ if utils.Log.IsDev() {
dp.UpdateMessage(fmt.Sprintf("Downloading %d/%d (%.2f%%) » %s", current, total, float32(current)/float32(total)*100, segment.URI), false)
} else {
dp.Update()
@@ -228,7 +230,7 @@ func downloadInfo(info formatInformation, file *os.File) error {
}
return nil
})
- if hasFFmpeg() {
+ if utils.HasFFmpeg() {
downloader.FFmpegOpts = make([]string, 0)
}
@@ -238,8 +240,8 @@ func downloadInfo(info formatInformation, file *os.File) error {
select {
case <-sig:
signal.Stop(sig)
- out.Exit("Exiting... (may take a few seconds)")
- out.Exit("To force exit press ctrl+c (again)")
+ utils.Log.Err("Exiting... (may take a few seconds)")
+ utils.Log.Err("To force exit press ctrl+c (again)")
cancel()
// os.Exit(1) is not called because an immediate exit after the cancel function does not let
// the download process enough time to stop gratefully. A result of this is that the temporary
@@ -248,38 +250,38 @@ func downloadInfo(info formatInformation, file *os.File) error {
// this is just here to end the goroutine and prevent it from running forever without a reason
}
}()
- out.Debug("Set up signal catcher")
+ utils.Log.Debug("Set up signal catcher")
- out.Info("Downloading episode `%s` to `%s`", info.Title, filepath.Base(file.Name()))
- out.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber)
- out.Info("\tAudio: %s", info.Audio)
- out.Info("\tSubtitle: %s", info.Subtitle)
- out.Info("\tResolution: %spx", info.Resolution)
- out.Info("\tFPS: %.2f", info.FPS)
- if err := info.format.Download(downloader); err != nil {
+ utils.Log.Info("Downloading episode `%s` to `%s`", info.Title, filepath.Base(file.Name()))
+ utils.Log.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber)
+ utils.Log.Info("\tAudio: %s", info.Audio)
+ utils.Log.Info("\tSubtitle: %s", info.Subtitle)
+ utils.Log.Info("\tResolution: %spx", info.Resolution)
+ utils.Log.Info("\tFPS: %.2f", info.FPS)
+ if err := info.Format.Download(downloader); err != nil {
return fmt.Errorf("error while downloading: %v", err)
}
dp.UpdateMessage("Download finished", false)
signal.Stop(sig)
- out.Debug("Stopped signal catcher")
+ utils.Log.Debug("Stopped signal catcher")
- out.Empty()
+ utils.Log.Empty()
return nil
}
-func downloadExtractEpisodes(url string) ([][]formatInformation, error) {
- episodes, err := extractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag))
+func downloadExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
+ episodes, err := utils.ExtractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag))
if err != nil {
return nil, err
}
japanese := episodes[0]
custom := episodes[1]
- sort.Sort(utils.EpisodesByNumber(japanese))
- sort.Sort(utils.EpisodesByNumber(custom))
+ sort.Sort(crunchyUtils.EpisodesByNumber(japanese))
+ sort.Sort(crunchyUtils.EpisodesByNumber(custom))
var errMessages []string
@@ -303,25 +305,25 @@ func downloadExtractEpisodes(url string) ([][]formatInformation, error) {
if len(errMessages) > 10 {
for _, msg := range errMessages[:10] {
- out.SetProgress(msg)
+ utils.Log.SetProcess(msg)
}
- out.SetProgress("... and %d more", len(errMessages)-10)
+ utils.Log.SetProcess("... and %d more", len(errMessages)-10)
} else {
for _, msg := range errMessages {
- out.SetProgress(msg)
+ utils.Log.SetProcess(msg)
}
}
- var infoFormat [][]formatInformation
- for _, season := range utils.SortEpisodesBySeason(final) {
- tmpFormatInformation := make([]formatInformation, 0)
+ var infoFormat [][]utils.FormatInformation
+ for _, season := range crunchyUtils.SortEpisodesBySeason(final) {
+ tmpFormatInformation := make([]utils.FormatInformation, 0)
for _, episode := range season {
format, err := episode.GetFormat(downloadResolutionFlag, crunchyroll.LOCALE(downloadSubtitleFlag), true)
if err != nil {
return nil, fmt.Errorf("error while receiving format for %s: %v", episode.Title, err)
}
- tmpFormatInformation = append(tmpFormatInformation, formatInformation{
- format: format,
+ tmpFormatInformation = append(tmpFormatInformation, utils.FormatInformation{
+ Format: format,
Title: episode.Title,
SeriesName: episode.SeriesTitle,
diff --git a/cli/commands/info/info.go b/cli/commands/info/info.go
new file mode 100644
index 0000000..a9a6ef0
--- /dev/null
+++ b/cli/commands/info/info.go
@@ -0,0 +1,40 @@
+package info
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchy-cli/cli/commands"
+ "github.com/ByteDream/crunchy-cli/utils"
+ crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/spf13/cobra"
+)
+
+var Cmd = &cobra.Command{
+ Use: "info",
+ Short: "Shows information about the logged in user",
+ Args: cobra.MinimumNArgs(0),
+
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := commands.LoadCrunchy(); err != nil {
+ return err
+ }
+
+ return info()
+ },
+}
+
+func info() error {
+ account, err := utils.Crunchy.Account()
+ if err != nil {
+ return err
+ }
+
+ fmt.Println("Username: ", account.Username)
+ fmt.Println("Email: ", account.Email)
+ fmt.Println("Premium: ", utils.Crunchy.Config.Premium)
+ fmt.Println("Interface language:", crunchyUtils.LocaleLanguage(account.PreferredCommunicationLanguage))
+ fmt.Println("Subtitle language: ", crunchyUtils.LocaleLanguage(account.PreferredContentSubtitleLanguage))
+ fmt.Println("Created: ", account.Created)
+ fmt.Println("Account ID: ", account.AccountID)
+
+ return nil
+}
diff --git a/commands/logger.go b/cli/commands/logger.go
similarity index 82%
rename from commands/logger.go
rename to cli/commands/logger.go
index fab8515..2f3ae37 100644
--- a/commands/logger.go
+++ b/cli/commands/logger.go
@@ -2,6 +2,7 @@ package commands
import (
"fmt"
+ "github.com/ByteDream/crunchy-cli/utils"
"io"
"log"
"os"
@@ -35,19 +36,7 @@ type progress struct {
stop bool
}
-type logger struct {
- DebugLog *log.Logger
- InfoLog *log.Logger
- ErrLog *log.Logger
-
- devView bool
-
- progress chan progress
- done chan interface{}
- lock sync.Mutex
-}
-
-func newLogger(debug, info, err bool) *logger {
+func NewLogger(debug, info, err bool) *Logger {
initPrefixBecauseWindowsSucksBallsHard()
debugLog, infoLog, errLog := log.New(io.Discard, prefix+" ", 0), log.New(io.Discard, prefix+" ", 0), log.New(io.Discard, prefix+" ", 0)
@@ -68,7 +57,7 @@ func newLogger(debug, info, err bool) *logger {
errLog = log.New(errLog.Writer(), "[err] ", 0)
}
- return &logger{
+ return &Logger{
DebugLog: debugLog,
InfoLog: infoLog,
ErrLog: errLog,
@@ -77,38 +66,52 @@ func newLogger(debug, info, err bool) *logger {
}
}
-func (l *logger) IsDev() bool {
+type Logger struct {
+ utils.Logger
+
+ DebugLog *log.Logger
+ InfoLog *log.Logger
+ ErrLog *log.Logger
+
+ devView bool
+
+ progress chan progress
+ done chan interface{}
+ lock sync.Mutex
+}
+
+func (l *Logger) IsDev() bool {
return l.devView
}
-func (l *logger) IsQuiet() bool {
+func (l *Logger) IsQuiet() bool {
return l.DebugLog.Writer() == io.Discard && l.InfoLog.Writer() == io.Discard && l.ErrLog.Writer() == io.Discard
}
-func (l *logger) Debug(format string, v ...interface{}) {
+func (l *Logger) Debug(format string, v ...interface{}) {
l.DebugLog.Printf(format, v...)
}
-func (l *logger) Info(format string, v ...interface{}) {
+func (l *Logger) Info(format string, v ...interface{}) {
l.InfoLog.Printf(format, v...)
}
-func (l *logger) Err(format string, v ...interface{}) {
+func (l *Logger) Warn(format string, v ...interface{}) {
+ l.Err(format, v...)
+}
+
+func (l *Logger) Err(format string, v ...interface{}) {
l.ErrLog.Printf(format, v...)
}
-func (l *logger) Exit(format string, v ...interface{}) {
- fmt.Fprintln(l.ErrLog.Writer(), fmt.Sprintf(format, v...))
-}
-
-func (l *logger) Empty() {
+func (l *Logger) Empty() {
if !l.devView && l.InfoLog.Writer() != io.Discard {
fmt.Println("")
}
}
-func (l *logger) SetProgress(format string, v ...interface{}) {
- if out.InfoLog.Writer() == io.Discard {
+func (l *Logger) SetProcess(format string, v ...interface{}) {
+ if l.InfoLog.Writer() == io.Discard {
return
} else if l.devView {
l.Debug(format, v...)
@@ -175,8 +178,8 @@ func (l *logger) SetProgress(format string, v ...interface{}) {
}()
}
-func (l *logger) StopProgress(format string, v ...interface{}) {
- if out.InfoLog.Writer() == io.Discard {
+func (l *Logger) StopProcess(format string, v ...interface{}) {
+ if l.InfoLog.Writer() == io.Discard {
return
} else if l.devView {
l.Debug(format, v...)
diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go
new file mode 100644
index 0000000..31155d8
--- /dev/null
+++ b/cli/commands/login/login.go
@@ -0,0 +1,158 @@
+package login
+
+import (
+ "bytes"
+ "fmt"
+ "github.com/ByteDream/crunchy-cli/cli/commands"
+ "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+var (
+ loginPersistentFlag bool
+ loginEncryptFlag bool
+
+ loginSessionIDFlag bool
+ loginEtpRtFlag bool
+)
+
+var Cmd = &cobra.Command{
+ Use: "login",
+ Short: "Login to crunchyroll",
+ Args: cobra.RangeArgs(1, 2),
+
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if loginSessionIDFlag {
+ return loginSessionID(args[0])
+ } else if loginEtpRtFlag {
+ return loginEtpRt(args[0])
+ } else {
+ return loginCredentials(args[0], args[1])
+ }
+ },
+}
+
+func init() {
+ Cmd.Flags().BoolVar(&loginPersistentFlag,
+ "persistent",
+ false,
+ "If the given credential should be stored persistent")
+ Cmd.Flags().BoolVar(&loginEncryptFlag,
+ "encrypt",
+ false,
+ "Encrypt the given credentials (won't do anything if --session-id is given or --persistent is not given)")
+
+ Cmd.Flags().BoolVar(&loginSessionIDFlag,
+ "session-id",
+ false,
+ "Use a session id to login instead of username and password")
+ Cmd.Flags().BoolVar(&loginEtpRtFlag,
+ "etp-rt",
+ false,
+ "Use a etp rt cookie to login instead of username and password")
+
+ Cmd.MarkFlagsMutuallyExclusive("session-id", "etp-rt")
+}
+
+func loginCredentials(user, password string) error {
+ utils.Log.Debug("Logging in via credentials")
+ c, err := crunchyroll.LoginWithCredentials(user, password, utils.SystemLocale(false), utils.Client)
+ if err != nil {
+ return err
+ }
+
+ if loginPersistentFlag {
+ var passwd []byte
+ if loginEncryptFlag {
+ for {
+ fmt.Print("Enter password: ")
+ passwd, err = commands.ReadLineSilent()
+ if err != nil {
+ return err
+ }
+ fmt.Println()
+
+ fmt.Print("Enter password again: ")
+ repasswd, err := commands.ReadLineSilent()
+ if err != nil {
+ return err
+ }
+ fmt.Println()
+
+ if bytes.Equal(passwd, repasswd) {
+ break
+ }
+ fmt.Println("Passwords does not match, try again")
+ }
+ }
+ if err = utils.SaveCredentialsPersistent(user, password, passwd); err != nil {
+ return err
+ }
+ if !loginEncryptFlag {
+ utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive. " +
+ "To encrypt it, use the `--encrypt` flag")
+ }
+ }
+ if err = utils.SaveSession(c); err != nil {
+ return err
+ }
+
+ if !loginPersistentFlag {
+ utils.Log.Info("Due to security reasons, you have to login again on the next reboot")
+ }
+
+ return nil
+}
+
+func loginSessionID(sessionID string) error {
+ utils.Log.Debug("Logging in via session id")
+ var c *crunchyroll.Crunchyroll
+ var err error
+ if c, err = crunchyroll.LoginWithSessionID(sessionID, utils.SystemLocale(false), utils.Client); err != nil {
+ return err
+ }
+
+ if loginPersistentFlag {
+ if err = utils.SaveSessionPersistent(c); err != nil {
+ return err
+ }
+ utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive")
+ }
+ if err = utils.SaveSession(c); err != nil {
+ return err
+ }
+
+ if !loginPersistentFlag {
+ utils.Log.Info("Due to security reasons, you have to login again on the next reboot")
+ }
+
+ return nil
+}
+
+func loginEtpRt(etpRt string) error {
+ utils.Log.Debug("Logging in via etp rt")
+ var c *crunchyroll.Crunchyroll
+ var err error
+ if c, err = crunchyroll.LoginWithEtpRt(etpRt, utils.SystemLocale(false), utils.Client); err != nil {
+ utils.Log.Err(err.Error())
+ os.Exit(1)
+ }
+
+ if loginPersistentFlag {
+ if err = utils.SaveSessionPersistent(c); err != nil {
+ return err
+ }
+ utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive")
+ }
+ if err = utils.SaveSession(c); err != nil {
+ return err
+ }
+
+ if !loginPersistentFlag {
+ utils.Log.Info("Due to security reasons, you have to login again on the next reboot")
+ }
+
+ return nil
+}
diff --git a/commands/unix.go b/cli/commands/unix.go
similarity index 95%
rename from commands/unix.go
rename to cli/commands/unix.go
index 955695c..ec69180 100644
--- a/commands/unix.go
+++ b/cli/commands/unix.go
@@ -19,7 +19,7 @@ func init() {
}
}
-func readLineSilent() ([]byte, error) {
+func ReadLineSilent() ([]byte, error) {
pid, err := setEcho(false)
if err != nil {
return nil, err
diff --git a/commands/update.go b/cli/commands/update/update.go
similarity index 67%
rename from commands/update.go
rename to cli/commands/update/update.go
index fb4c1e9..6587ca2 100644
--- a/commands/update.go
+++ b/cli/commands/update/update.go
@@ -1,8 +1,9 @@
-package commands
+package update
import (
"encoding/json"
"fmt"
+ "github.com/ByteDream/crunchy-cli/utils"
"github.com/spf13/cobra"
"io"
"os"
@@ -15,7 +16,7 @@ var (
updateInstallFlag bool
)
-var updateCmd = &cobra.Command{
+var Cmd = &cobra.Command{
Use: "update",
Short: "Check if updates are available",
Args: cobra.MaximumNArgs(0),
@@ -26,19 +27,17 @@ var updateCmd = &cobra.Command{
}
func init() {
- updateCmd.Flags().BoolVarP(&updateInstallFlag,
+ Cmd.Flags().BoolVarP(&updateInstallFlag,
"install",
"i",
false,
"If set and a new version is available, the new version gets installed")
-
- rootCmd.AddCommand(updateCmd)
}
func update() error {
var release map[string]interface{}
- resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
+ resp, err := utils.Client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
if err != nil {
return err
}
@@ -48,8 +47,8 @@ func update() error {
}
releaseVersion := strings.TrimPrefix(release["tag_name"].(string), "v")
- if Version == "development" {
- out.Info("Development version, update service not available")
+ if utils.Version == "development" {
+ utils.Log.Info("Development version, update service not available")
return nil
}
@@ -58,17 +57,17 @@ func update() error {
return fmt.Errorf("latest tag name (%s) is not parsable", releaseVersion)
}
- internalVersion := strings.SplitN(Version, ".", 4)
+ internalVersion := strings.SplitN(utils.Version, ".", 4)
if len(internalVersion) != 3 {
- return fmt.Errorf("internal version (%s) is not parsable", Version)
+ return fmt.Errorf("internal version (%s) is not parsable", utils.Version)
}
- out.Info("Installed version is %s", Version)
+ utils.Log.Info("Installed version is %s", utils.Version)
var hasUpdate bool
for i := 0; i < 3; i++ {
if latestRelease[i] < internalVersion[i] {
- out.Info("Local version is newer than version in latest release (%s)", releaseVersion)
+ utils.Log.Info("Local version is newer than version in latest release (%s)", releaseVersion)
return nil
} else if latestRelease[i] > internalVersion[i] {
hasUpdate = true
@@ -76,11 +75,11 @@ func update() error {
}
if !hasUpdate {
- out.Info("Version is up-to-date")
+ utils.Log.Info("Version is up-to-date")
return nil
}
- out.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
+ utils.Log.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
if updateInstallFlag {
if runtime.GOARCH != "amd64" {
@@ -93,7 +92,7 @@ func update() error {
case "linux":
yayCommand := exec.Command("pacman -Q crunchy-cli")
if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
- out.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
+ utils.Log.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
return nil
}
downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion)
@@ -106,7 +105,7 @@ func update() error {
"You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS)
}
- out.SetProgress("Updating executable %s", os.Args[0])
+ utils.Log.SetProcess("Updating executable %s", os.Args[0])
perms, err := os.Stat(os.Args[0])
if err != nil {
@@ -119,7 +118,7 @@ func update() error {
}
defer executeFile.Close()
- resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
+ resp, err := utils.Client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
if err != nil {
return err
}
@@ -129,7 +128,7 @@ func update() error {
return err
}
- out.StopProgress("Updated executable %s", os.Args[0])
+ utils.Log.StopProcess("Updated executable %s", os.Args[0])
}
return nil
diff --git a/cli/commands/utils.go b/cli/commands/utils.go
new file mode 100644
index 0000000..924cd4d
--- /dev/null
+++ b/cli/commands/utils.go
@@ -0,0 +1,125 @@
+package commands
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchy-cli/utils"
+ "os"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+type DownloadProgress struct {
+ Prefix string
+ Message string
+
+ Total int
+ Current int
+
+ Dev bool
+ Quiet bool
+
+ lock sync.Mutex
+}
+
+func (dp *DownloadProgress) Update() {
+ dp.update("", false)
+}
+
+func (dp *DownloadProgress) UpdateMessage(msg string, permanent bool) {
+ dp.update(msg, permanent)
+}
+
+func (dp *DownloadProgress) update(msg string, permanent bool) {
+ if dp.Quiet {
+ return
+ }
+
+ if dp.Current >= dp.Total {
+ return
+ }
+
+ dp.lock.Lock()
+ defer dp.lock.Unlock()
+ dp.Current++
+
+ if msg == "" {
+ msg = dp.Message
+ }
+ if permanent {
+ dp.Message = msg
+ }
+
+ if dp.Dev {
+ fmt.Printf("%s%s\n", dp.Prefix, msg)
+ return
+ }
+
+ percentage := float32(dp.Current) / float32(dp.Total) * 100
+
+ pre := fmt.Sprintf("%s%s [", dp.Prefix, msg)
+ post := fmt.Sprintf("]%4d%% %8d/%d", int(percentage), dp.Current, dp.Total)
+
+ // I don't really know why +2 is needed here but without it the Printf below would not print to the line end
+ progressWidth := terminalWidth() - len(pre) - len(post) + 2
+ repeatCount := int(percentage / float32(100) * float32(progressWidth))
+ // it can be lower than zero when the terminal is very tiny
+ if repeatCount < 0 {
+ repeatCount = 0
+ }
+ progressPercentage := strings.Repeat("=", repeatCount)
+ if dp.Current != dp.Total {
+ progressPercentage += ">"
+ }
+
+ fmt.Printf("\r%s%-"+fmt.Sprint(progressWidth)+"s%s", pre, progressPercentage, post)
+}
+
+func terminalWidth() int {
+ if runtime.GOOS != "windows" {
+ cmd := exec.Command("stty", "size")
+ cmd.Stdin = os.Stdin
+ res, err := cmd.Output()
+ if err != nil {
+ return 60
+ }
+ // on alpine linux the command `stty size` does not respond the terminal size
+ // but something like "stty: standard input". this may also apply to other systems
+ splitOutput := strings.SplitN(strings.ReplaceAll(string(res), "\n", ""), " ", 2)
+ if len(splitOutput) == 1 {
+ return 60
+ }
+ width, err := strconv.Atoi(splitOutput[1])
+ if err != nil {
+ return 60
+ }
+ return width
+ }
+ return 60
+}
+
+func LoadCrunchy() error {
+ var encryptionKey []byte
+
+ if utils.IsTempSession() {
+ encryptionKey = nil
+ } else {
+ if encrypted, err := utils.IsSavedSessionEncrypted(); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("to use this command, login first. Type `%s login -h` to get help", os.Args[0])
+ }
+ return err
+ } else if encrypted {
+ encryptionKey, err = ReadLineSilent()
+ if err != nil {
+ return fmt.Errorf("failed to read password")
+ }
+ }
+ }
+
+ var err error
+ utils.Crunchy, err = utils.LoadSession(encryptionKey)
+ return err
+}
diff --git a/commands/windows.go b/cli/commands/windows.go
similarity index 94%
rename from commands/windows.go
rename to cli/commands/windows.go
index 2ae47b5..a9bce74 100644
--- a/commands/windows.go
+++ b/cli/commands/windows.go
@@ -9,7 +9,7 @@ import (
)
// https://github.com/bgentry/speakeasy/blob/master/speakeasy_windows.go
-func readLineSilent() ([]byte, error) {
+func ReadLineSilent() ([]byte, error) {
var oldMode uint32
if err := syscall.GetConsoleMode(syscall.Stdin, &oldMode); err != nil {
diff --git a/cli/root.go b/cli/root.go
new file mode 100644
index 0000000..ebb5079
--- /dev/null
+++ b/cli/root.go
@@ -0,0 +1,85 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "github.com/ByteDream/crunchy-cli/cli/commands"
+ "github.com/ByteDream/crunchy-cli/cli/commands/archive"
+ "github.com/ByteDream/crunchy-cli/cli/commands/download"
+ "github.com/ByteDream/crunchy-cli/cli/commands/info"
+ "github.com/ByteDream/crunchy-cli/cli/commands/login"
+ "github.com/ByteDream/crunchy-cli/cli/commands/update"
+ "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/spf13/cobra"
+ "os"
+ "runtime/debug"
+ "strings"
+)
+
+var (
+ quietFlag bool
+ verboseFlag bool
+
+ proxyFlag string
+
+ useragentFlag string
+)
+
+var RootCmd = &cobra.Command{
+ Use: "crunchy-cli",
+ Version: utils.Version,
+ Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki",
+
+ SilenceErrors: true,
+ SilenceUsage: true,
+
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
+ if verboseFlag {
+ utils.Log = commands.NewLogger(true, true, true)
+ } else if quietFlag {
+ utils.Log = commands.NewLogger(false, false, false)
+ }
+
+ utils.Log.Debug("Executing `%s` command with %d arg(s)", cmd.Name(), len(args))
+
+ utils.Client, err = utils.CreateOrDefaultClient(proxyFlag, useragentFlag)
+ return
+ },
+}
+
+func init() {
+ RootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Disable all output")
+ RootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Adds debug messages to the normal output")
+
+ RootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
+
+ RootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", utils.Version), "Useragent to do all request with")
+
+ RootCmd.AddCommand(archive.Cmd)
+ RootCmd.AddCommand(download.Cmd)
+ RootCmd.AddCommand(info.Cmd)
+ RootCmd.AddCommand(login.Cmd)
+ RootCmd.AddCommand(update.Cmd)
+
+ utils.Log = commands.NewLogger(false, true, true)
+}
+
+func Execute() {
+ RootCmd.CompletionOptions.HiddenDefaultCmd = true
+ defer func() {
+ if r := recover(); r != nil {
+ if utils.Log.IsDev() {
+ utils.Log.Err("%v: %s", r, debug.Stack())
+ } else {
+ utils.Log.Err("Unexpected error: %v", r)
+ }
+ os.Exit(1)
+ }
+ }()
+ if err := RootCmd.Execute(); err != nil {
+ if !strings.HasSuffix(err.Error(), context.Canceled.Error()) {
+ utils.Log.Err("An error occurred: %v", err)
+ }
+ os.Exit(1)
+ }
+}
diff --git a/commands/info.go b/commands/info.go
deleted file mode 100644
index fe9693d..0000000
--- a/commands/info.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package commands
-
-import (
- "fmt"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
- "github.com/spf13/cobra"
-)
-
-var infoCmd = &cobra.Command{
- Use: "info",
- Short: "Shows information about the logged in user",
- Args: cobra.MinimumNArgs(0),
-
- RunE: func(cmd *cobra.Command, args []string) error {
- loadCrunchy()
-
- return info()
- },
-}
-
-func init() {
- rootCmd.AddCommand(infoCmd)
-}
-
-func info() error {
- account, err := crunchy.Account()
- if err != nil {
- return err
- }
-
- fmt.Println("Username: ", account.Username)
- fmt.Println("Email: ", account.Email)
- fmt.Println("Premium: ", crunchy.Config.Premium)
- fmt.Println("Interface language:", utils.LocaleLanguage(account.PreferredCommunicationLanguage))
- fmt.Println("Subtitle language: ", utils.LocaleLanguage(account.PreferredContentSubtitleLanguage))
- fmt.Println("Created: ", account.Created)
- fmt.Println("Account ID: ", account.AccountID)
-
- return nil
-}
diff --git a/commands/login.go b/commands/login.go
deleted file mode 100644
index a710bb6..0000000
--- a/commands/login.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package commands
-
-import (
- "bytes"
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "crypto/sha256"
- "fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
- "github.com/spf13/cobra"
- "io"
- "os"
- "path/filepath"
-)
-
-var (
- loginPersistentFlag bool
- loginEncryptFlag bool
-
- loginSessionIDFlag bool
- loginEtpRtFlag bool
-)
-
-var loginCmd = &cobra.Command{
- Use: "login",
- Short: "Login to crunchyroll",
- Args: cobra.RangeArgs(1, 2),
-
- RunE: func(cmd *cobra.Command, args []string) error {
- if loginSessionIDFlag {
- return loginSessionID(args[0])
- } else if loginEtpRtFlag {
- return loginEtpRt(args[0])
- } else {
- return loginCredentials(args[0], args[1])
- }
- },
-}
-
-func init() {
- loginCmd.Flags().BoolVar(&loginPersistentFlag,
- "persistent",
- false,
- "If the given credential should be stored persistent")
- loginCmd.Flags().BoolVar(&loginEncryptFlag,
- "encrypt",
- false,
- "Encrypt the given credentials (won't do anything if --session-id is given or --persistent is not given)")
-
- loginCmd.Flags().BoolVar(&loginSessionIDFlag,
- "session-id",
- false,
- "Use a session id to login instead of username and password")
- loginCmd.Flags().BoolVar(&loginEtpRtFlag,
- "etp-rt",
- false,
- "Use a etp rt cookie to login instead of username and password")
-
- rootCmd.AddCommand(loginCmd)
-}
-
-func loginCredentials(user, password string) error {
- out.Debug("Logging in via credentials")
- c, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client)
- if err != nil {
- return err
- }
-
- if loginPersistentFlag {
- if configDir, err := os.UserConfigDir(); err != nil {
- return fmt.Errorf("could not save credentials persistent: %w", err)
- } else {
- var credentials []byte
-
- if loginEncryptFlag {
- var passwd []byte
-
- for {
- fmt.Print("Enter password: ")
- passwd, err = readLineSilent()
- if err != nil {
- return err
- }
- fmt.Println()
-
- fmt.Print("Enter password again: ")
- repasswd, err := readLineSilent()
- if err != nil {
- return err
- }
- fmt.Println()
-
- if !bytes.Equal(passwd, repasswd) {
- fmt.Println("Passwords does not match, try again")
- continue
- }
-
- hashedPassword := sha256.Sum256(passwd)
- block, err := aes.NewCipher(hashedPassword[:])
- if err != nil {
- out.Err("Failed to create block: %w", err)
- os.Exit(1)
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- out.Err("Failed to create gcm: %w", err)
- os.Exit(1)
- }
- nonce := make([]byte, gcm.NonceSize())
- if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
- out.Err("Failed to fill nonce: %w", err)
- os.Exit(1)
- }
-
- b := gcm.Seal(nonce, nonce, []byte(fmt.Sprintf("%s\n%s", user, password)), nil)
- credentials = append([]byte("aes:"), b...)
-
- break
- }
- } else {
- credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
- }
-
- os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), credentials, 0600); err != nil {
- return err
- }
- if !loginEncryptFlag {
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+
- "To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchy-cli", "crunchy"))
- }
- }
- }
-
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
- return err
- }
-
- if !loginPersistentFlag {
- out.Info("Due to security reasons, you have to login again on the next reboot")
- }
-
- return nil
-}
-
-func loginSessionID(sessionID string) error {
- out.Debug("Logging in via session id")
- var c *crunchyroll.Crunchyroll
- var err error
- if c, err = crunchyroll.LoginWithSessionID(sessionID, systemLocale(false), client); err != nil {
- out.Err(err.Error())
- os.Exit(1)
- }
-
- if loginPersistentFlag {
- if configDir, err := os.UserConfigDir(); err != nil {
- return fmt.Errorf("could not save credentials persistent: %w", err)
- } else {
- os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
- return err
- }
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
- }
- }
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
- return err
- }
-
- if !loginPersistentFlag {
- out.Info("Due to security reasons, you have to login again on the next reboot")
- }
-
- return nil
-}
-
-func loginEtpRt(etpRt string) error {
- out.Debug("Logging in via etp rt")
- if _, err := crunchyroll.LoginWithEtpRt(etpRt, systemLocale(false), client); err != nil {
- out.Err(err.Error())
- os.Exit(1)
- }
-
- var err error
- if loginPersistentFlag {
- if configDir, err := os.UserConfigDir(); err != nil {
- return fmt.Errorf("could not save credentials persistent: %w", err)
- } else {
- os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
- if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(etpRt), 0600); err != nil {
- return err
- }
- out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
- }
- }
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil {
- return err
- }
-
- if !loginPersistentFlag {
- out.Info("Due to security reasons, you have to login again on the next reboot")
- }
-
- return nil
-}
diff --git a/commands/root.go b/commands/root.go
deleted file mode 100644
index ffcf35a..0000000
--- a/commands/root.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package commands
-
-import (
- "context"
- "fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
- "github.com/spf13/cobra"
- "net/http"
- "os"
- "runtime/debug"
- "strings"
-)
-
-var Version = "development"
-
-var (
- client *http.Client
- crunchy *crunchyroll.Crunchyroll
- out = newLogger(false, true, true)
-
- quietFlag bool
- verboseFlag bool
-
- proxyFlag string
-
- useragentFlag string
-)
-
-var rootCmd = &cobra.Command{
- Use: "crunchy-cli",
- Version: Version,
- Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki",
-
- SilenceErrors: true,
- SilenceUsage: true,
-
- PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
- if verboseFlag {
- out = newLogger(true, true, true)
- } else if quietFlag {
- out = newLogger(false, false, false)
- }
-
- out.Debug("Executing `%s` command with %d arg(s)", cmd.Name(), len(args))
-
- client, err = createOrDefaultClient(proxyFlag, useragentFlag)
- return
- },
-}
-
-func init() {
- rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Disable all output")
- rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Adds debug messages to the normal output")
-
- rootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
-
- rootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", Version), "Useragent to do all request with")
-}
-
-func Execute() {
- rootCmd.CompletionOptions.HiddenDefaultCmd = true
- defer func() {
- if r := recover(); r != nil {
- if out.IsDev() {
- out.Err("%v: %s", r, debug.Stack())
- } else {
- out.Err("Unexpected error: %v", r)
- }
- os.Exit(1)
- }
- }()
- if err := rootCmd.Execute(); err != nil {
- if !strings.HasSuffix(err.Error(), context.Canceled.Error()) {
- out.Exit("An error occurred: %v", err)
- }
- os.Exit(1)
- }
-}
diff --git a/commands/utils.go b/commands/utils.go
deleted file mode 100644
index 486c61f..0000000
--- a/commands/utils.go
+++ /dev/null
@@ -1,485 +0,0 @@
-package commands
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/sha256"
- "fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
- "net/http"
- "net/url"
- "os"
- "os/exec"
- "path/filepath"
- "reflect"
- "regexp"
- "runtime"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-)
-
-var (
- // ahh i love windows :)))
- invalidWindowsChars = []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"}
- invalidNotWindowsChars = []string{"/"}
-)
-
-var urlFilter = regexp.MustCompile(`(S(\d+))?(E(\d+))?((-)(S(\d+))?(E(\d+))?)?(,|$)`)
-
-// systemLocale receives the system locale
-// https://stackoverflow.com/questions/51829386/golang-get-system-language/51831590#51831590
-func systemLocale(verbose bool) crunchyroll.LOCALE {
- if runtime.GOOS != "windows" {
- if lang, ok := os.LookupEnv("LANG"); ok {
- var l crunchyroll.LOCALE
- if preSuffix := strings.Split(strings.Split(lang, ".")[0], "_"); len(preSuffix) == 1 {
- l = crunchyroll.LOCALE(preSuffix[0])
- } else {
- prefix := strings.Split(lang, "_")[0]
- l = crunchyroll.LOCALE(fmt.Sprintf("%s-%s", prefix, preSuffix[1]))
- }
- if !utils.ValidateLocale(l) {
- if verbose {
- out.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US)
- }
- l = crunchyroll.US
- }
- return l
- }
- } else {
- cmd := exec.Command("powershell", "Get-Culture | select -exp Name")
- if output, err := cmd.Output(); err == nil {
- l := crunchyroll.LOCALE(strings.Trim(string(output), "\r\n"))
- if !utils.ValidateLocale(l) {
- if verbose {
- out.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US)
- }
- l = crunchyroll.US
- }
- return l
- }
- }
- if verbose {
- out.Err("Failed to get locale, using %s", crunchyroll.US)
- }
- return crunchyroll.US
-}
-
-func allLocalesAsStrings() (locales []string) {
- for _, locale := range utils.AllLocales {
- locales = append(locales, string(locale))
- }
- sort.Strings(locales)
- return
-}
-
-type headerRoundTripper struct {
- http.RoundTripper
- header map[string]string
-}
-
-func (rht headerRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
- resp, err := rht.RoundTripper.RoundTrip(r)
- if err != nil {
- return nil, err
- }
- for k, v := range rht.header {
- resp.Header.Set(k, v)
- }
- return resp, nil
-}
-
-func createOrDefaultClient(proxy, useragent string) (*http.Client, error) {
- if proxy == "" {
- return http.DefaultClient, nil
- } else {
- out.Info("Using custom proxy %s", proxy)
- proxyURL, err := url.Parse(proxy)
- if err != nil {
- return nil, err
- }
-
- var rt http.RoundTripper = &http.Transport{
- DisableCompression: true,
- Proxy: http.ProxyURL(proxyURL),
- }
- if useragent != "" {
- rt = headerRoundTripper{
- RoundTripper: rt,
- header: map[string]string{"User-Agent": useragent},
- }
- }
-
- client := &http.Client{
- Transport: rt,
- Timeout: 30 * time.Second,
- }
- return client, nil
- }
-}
-
-func freeFileName(filename string) (string, bool) {
- ext := filepath.Ext(filename)
- base := strings.TrimSuffix(filename, ext)
- // checks if a .tar stands before the "actual" file ending
- if extraExt := filepath.Ext(base); extraExt == ".tar" {
- ext = extraExt + ext
- base = strings.TrimSuffix(base, extraExt)
- }
- j := 0
- for ; ; j++ {
- if _, stat := os.Stat(filename); stat != nil && !os.IsExist(stat) {
- break
- }
- filename = fmt.Sprintf("%s (%d)%s", base, j+1, ext)
- }
- return filename, j != 0
-}
-
-func loadCrunchy() {
- out.SetProgress("Logging in")
-
- tmpFilePath := filepath.Join(os.TempDir(), ".crunchy")
- if _, statErr := os.Stat(tmpFilePath); !os.IsNotExist(statErr) {
- body, err := os.ReadFile(tmpFilePath)
- if err != nil {
- out.StopProgress("Failed to read login information: %v", err)
- os.Exit(1)
- }
- if crunchy, err = crunchyroll.LoginWithEtpRt(string(body), systemLocale(true), client); err != nil {
- out.Debug("Failed to login with temp etp rt cookie: %v", err)
- } else {
- out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
-
- out.StopProgress("Logged in")
- return
- }
- }
-
- if configDir, err := os.UserConfigDir(); err == nil {
- persistentFilePath := filepath.Join(configDir, "crunchy-cli", "crunchy")
- if _, statErr := os.Stat(persistentFilePath); statErr == nil {
- body, err := os.ReadFile(persistentFilePath)
- if err != nil {
- out.StopProgress("Failed to read login information: %v", err)
- os.Exit(1)
- }
- split := strings.SplitN(string(body), "\n", 2)
- if len(split) == 1 || split[1] == "" {
- if strings.HasPrefix(split[0], "aes:") {
- encrypted := body[4:]
-
- out.StopProgress("Credentials are encrypted")
- fmt.Print("Enter password to encrypt it: ")
- passwd, err := readLineSilent()
- fmt.Println()
- if err != nil {
- out.Err("Failed to read password; %w", err)
- os.Exit(1)
- }
- out.SetProgress("Logging in")
-
- hashedPassword := sha256.Sum256(passwd)
- block, err := aes.NewCipher(hashedPassword[:])
- if err != nil {
- out.Err("Failed to create block: %w", err)
- os.Exit(1)
- }
- gcm, err := cipher.NewGCM(block)
- if err != nil {
- out.Err("Failed to create gcm: %w", err)
- os.Exit(1)
- }
- nonce, c := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():]
-
- b, err := gcm.Open(nil, nonce, c, nil)
- if err != nil {
- out.StopProgress("Invalid password")
- os.Exit(1)
- }
- split = strings.SplitN(string(b), "\n", 2)
- }
- }
-
- if len(split) == 2 {
- if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
- out.StopProgress(err.Error())
- os.Exit(1)
- }
- out.Debug("Logged in with credentials")
- } else {
- if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil {
- out.StopProgress(err.Error())
- os.Exit(1)
- }
- out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
- }
-
- // the etp rt is written to a temp file to reduce the amount of re-logging in.
- // it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
- os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
-
- out.StopProgress("Logged in")
- return
- }
- }
-
- out.StopProgress("To use this command, login first. Type `%s login -h` to get help", os.Args[0])
- os.Exit(1)
-}
-
-func hasFFmpeg() bool {
- return exec.Command("ffmpeg", "-h").Run() == nil
-}
-
-func terminalWidth() int {
- if runtime.GOOS != "windows" {
- cmd := exec.Command("stty", "size")
- cmd.Stdin = os.Stdin
- res, err := cmd.Output()
- if err != nil {
- return 60
- }
- // on alpine linux the command `stty size` does not respond the terminal size
- // but something like "stty: standard input". this may also apply to other systems
- splitOutput := strings.SplitN(strings.ReplaceAll(string(res), "\n", ""), " ", 2)
- if len(splitOutput) == 1 {
- return 60
- }
- width, err := strconv.Atoi(splitOutput[1])
- if err != nil {
- return 60
- }
- return width
- }
- return 60
-}
-
-func generateFilename(name, directory string) string {
- if runtime.GOOS != "windows" {
- for _, char := range invalidNotWindowsChars {
- name = strings.ReplaceAll(name, char, "")
- }
- out.Debug("Replaced invalid characters (not windows)")
- } else {
- for _, char := range invalidWindowsChars {
- name = strings.ReplaceAll(name, char, "")
- }
- out.Debug("Replaced invalid characters (windows)")
- }
-
- filename, changed := freeFileName(filepath.Join(directory, name))
- if changed {
- out.Debug("File `%s` already exists, changing name to `%s`", filepath.Base(name), filepath.Base(filename))
- }
-
- return filename
-}
-
-func extractEpisodes(url string, locales ...crunchyroll.LOCALE) ([][]*crunchyroll.Episode, error) {
- var matches [][]string
-
- lastOpen := strings.LastIndex(url, "[")
- if strings.HasSuffix(url, "]") && lastOpen != -1 && lastOpen < len(url) {
- matches = urlFilter.FindAllStringSubmatch(url[lastOpen+1:len(url)-1], -1)
-
- var all string
- for _, match := range matches {
- all += match[0]
- }
- if all != url[lastOpen+1:len(url)-1] {
- return nil, fmt.Errorf("invalid episode filter")
- }
- url = url[:lastOpen]
- }
-
- final := make([][]*crunchyroll.Episode, len(locales))
- episodes, err := crunchy.ExtractEpisodesFromUrl(url, locales...)
- if err != nil {
- return nil, fmt.Errorf("failed to get episodes: %v", err)
- }
-
- if len(episodes) == 0 {
- return nil, fmt.Errorf("no episodes found")
- }
-
- if matches != nil {
- for _, match := range matches {
- fromSeason, fromEpisode, toSeason, toEpisode := -1, -1, -1, -1
- if match[2] != "" {
- fromSeason, _ = strconv.Atoi(match[2])
- }
- if match[4] != "" {
- fromEpisode, _ = strconv.Atoi(match[4])
- }
- if match[8] != "" {
- toSeason, _ = strconv.Atoi(match[8])
- }
- if match[10] != "" {
- toEpisode, _ = strconv.Atoi(match[10])
- }
-
- if match[6] != "-" {
- toSeason = fromSeason
- toEpisode = fromEpisode
- }
-
- tmpEps := make([]*crunchyroll.Episode, 0)
- for _, episode := range episodes {
- if fromSeason != -1 && (episode.SeasonNumber < fromSeason || (fromEpisode != -1 && episode.EpisodeNumber < fromEpisode)) {
- continue
- } else if fromSeason == -1 && fromEpisode != -1 && episode.EpisodeNumber < fromEpisode {
- continue
- } else if toSeason != -1 && (episode.SeasonNumber > toSeason || (toEpisode != -1 && episode.EpisodeNumber > toEpisode)) {
- continue
- } else if toSeason == -1 && toEpisode != -1 && episode.EpisodeNumber > toEpisode {
- continue
- } else {
- tmpEps = append(tmpEps, episode)
- }
- }
-
- if len(tmpEps) == 0 {
- return nil, fmt.Errorf("no episodes are matching the given filter")
- }
-
- episodes = tmpEps
- }
- }
-
- localeSorted, err := utils.SortEpisodesByAudio(episodes)
- if err != nil {
- return nil, fmt.Errorf("failed to get audio locale: %v", err)
- }
- for i, locale := range locales {
- final[i] = append(final[i], localeSorted[locale]...)
- }
-
- return final, nil
-}
-
-type formatInformation struct {
- // the format to download
- format *crunchyroll.Format
-
- // additional formats which are only used by archive.go
- additionalFormats []*crunchyroll.Format
-
- Title string `json:"title"`
- SeriesName string `json:"series_name"`
- SeasonName string `json:"season_name"`
- SeasonNumber int `json:"season_number"`
- EpisodeNumber int `json:"episode_number"`
- Resolution string `json:"resolution"`
- FPS float64 `json:"fps"`
- Audio crunchyroll.LOCALE `json:"audio"`
- Subtitle crunchyroll.LOCALE `json:"subtitle"`
-}
-
-func (fi formatInformation) Format(source string) string {
- fields := reflect.TypeOf(fi)
- values := reflect.ValueOf(fi)
-
- for i := 0; i < fields.NumField(); i++ {
- var valueAsString string
- switch value := values.Field(i); value.Kind() {
- case reflect.String:
- valueAsString = value.String()
- case reflect.Int:
- valueAsString = fmt.Sprintf("%02d", value.Int())
- case reflect.Float64:
- valueAsString = fmt.Sprintf("%.2f", value.Float())
- case reflect.Bool:
- valueAsString = fields.Field(i).Tag.Get("json")
- if !value.Bool() {
- valueAsString = "no " + valueAsString
- }
- }
-
- if runtime.GOOS != "windows" {
- for _, char := range invalidNotWindowsChars {
- valueAsString = strings.ReplaceAll(valueAsString, char, "")
- }
- out.Debug("Replaced invalid characters (not windows)")
- } else {
- for _, char := range invalidWindowsChars {
- valueAsString = strings.ReplaceAll(valueAsString, char, "")
- }
- out.Debug("Replaced invalid characters (windows)")
- }
-
- source = strings.ReplaceAll(source, "{"+fields.Field(i).Tag.Get("json")+"}", valueAsString)
- }
-
- return source
-}
-
-type downloadProgress struct {
- Prefix string
- Message string
-
- Total int
- Current int
-
- Dev bool
- Quiet bool
-
- lock sync.Mutex
-}
-
-func (dp *downloadProgress) Update() {
- dp.update("", false)
-}
-
-func (dp *downloadProgress) UpdateMessage(msg string, permanent bool) {
- dp.update(msg, permanent)
-}
-
-func (dp *downloadProgress) update(msg string, permanent bool) {
- if dp.Quiet {
- return
- }
-
- if dp.Current >= dp.Total {
- return
- }
-
- dp.lock.Lock()
- defer dp.lock.Unlock()
- dp.Current++
-
- if msg == "" {
- msg = dp.Message
- }
- if permanent {
- dp.Message = msg
- }
-
- if dp.Dev {
- fmt.Printf("%s%s\n", dp.Prefix, msg)
- return
- }
-
- percentage := float32(dp.Current) / float32(dp.Total) * 100
-
- pre := fmt.Sprintf("%s%s [", dp.Prefix, msg)
- post := fmt.Sprintf("]%4d%% %8d/%d", int(percentage), dp.Current, dp.Total)
-
- // I don't really know why +2 is needed here but without it the Printf below would not print to the line end
- progressWidth := terminalWidth() - len(pre) - len(post) + 2
- repeatCount := int(percentage / float32(100) * float32(progressWidth))
- // it can be lower than zero when the terminal is very tiny
- if repeatCount < 0 {
- repeatCount = 0
- }
- progressPercentage := strings.Repeat("=", repeatCount)
- if dp.Current != dp.Total {
- progressPercentage += ">"
- }
-
- fmt.Printf("\r%s%-"+fmt.Sprint(progressWidth)+"s%s", pre, progressPercentage, post)
-}
diff --git a/crunchy-cli.1 b/crunchy-cli.1
index d9b1075..ebadb9c 100644
--- a/crunchy-cli.1
+++ b/crunchy-cli.1
@@ -61,7 +61,7 @@ NOTE: The credentials are stored in plain text and if you not use \fB--session-i
Login via a session id (which can be extracted from a crunchyroll browser cookie) instead of using username and password.
.SH DOWNLOAD COMMAND
-A command to simply download videos. The output file is stored as a \fI.ts\fR file. \fIffmpeg\fR has to be installed if you want to change the format the videos are stored in.
+A command to simply download videos. The output file is stored as a \fI.ts\fR file. \fIffmpeg\fR has to be installed if you want to change the Format the videos are stored in.
.TP
\fB-a, --audio AUDIO\fR
diff --git a/go.mod b/go.mod
index 2c0abc0..909378c 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/ByteDream/crunchy-cli
go 1.18
require (
- github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c
+ github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.5.0
)
diff --git a/go.sum b/go.sum
index 626a2c5..5603301 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY=
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
+github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1 h1:hOL4xzDc6oCcrpf6GrrdUgvqwsQo6dI2zL4nA8rl9hg=
+github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
diff --git a/main.go b/main.go
index f9c3a90..7275a13 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,9 @@
package main
-import "github.com/ByteDream/crunchy-cli/commands"
+import (
+ "github.com/ByteDream/crunchy-cli/cli"
+)
func main() {
- commands.Execute()
+ cli.Execute()
}
diff --git a/utils/extract.go b/utils/extract.go
new file mode 100644
index 0000000..ba52242
--- /dev/null
+++ b/utils/extract.go
@@ -0,0 +1,94 @@
+package utils
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var urlFilter = regexp.MustCompile(`(S(\d+))?(E(\d+))?((-)(S(\d+))?(E(\d+))?)?(,|$)`)
+
+func ExtractEpisodes(url string, locales ...crunchyroll.LOCALE) ([][]*crunchyroll.Episode, error) {
+ var matches [][]string
+
+ lastOpen := strings.LastIndex(url, "[")
+ if strings.HasSuffix(url, "]") && lastOpen != -1 && lastOpen < len(url) {
+ matches = urlFilter.FindAllStringSubmatch(url[lastOpen+1:len(url)-1], -1)
+
+ var all string
+ for _, match := range matches {
+ all += match[0]
+ }
+ if all != url[lastOpen+1:len(url)-1] {
+ return nil, fmt.Errorf("invalid episode filter")
+ }
+ url = url[:lastOpen]
+ }
+
+ final := make([][]*crunchyroll.Episode, len(locales))
+ episodes, err := Crunchy.ExtractEpisodesFromUrl(url, locales...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get episodes: %v", err)
+ }
+
+ if len(episodes) == 0 {
+ return nil, fmt.Errorf("no episodes found")
+ }
+
+ if matches != nil {
+ for _, match := range matches {
+ fromSeason, fromEpisode, toSeason, toEpisode := -1, -1, -1, -1
+ if match[2] != "" {
+ fromSeason, _ = strconv.Atoi(match[2])
+ }
+ if match[4] != "" {
+ fromEpisode, _ = strconv.Atoi(match[4])
+ }
+ if match[8] != "" {
+ toSeason, _ = strconv.Atoi(match[8])
+ }
+ if match[10] != "" {
+ toEpisode, _ = strconv.Atoi(match[10])
+ }
+
+ if match[6] != "-" {
+ toSeason = fromSeason
+ toEpisode = fromEpisode
+ }
+
+ tmpEps := make([]*crunchyroll.Episode, 0)
+ for _, episode := range episodes {
+ if fromSeason != -1 && (episode.SeasonNumber < fromSeason || (fromEpisode != -1 && episode.EpisodeNumber < fromEpisode)) {
+ continue
+ } else if fromSeason == -1 && fromEpisode != -1 && episode.EpisodeNumber < fromEpisode {
+ continue
+ } else if toSeason != -1 && (episode.SeasonNumber > toSeason || (toEpisode != -1 && episode.EpisodeNumber > toEpisode)) {
+ continue
+ } else if toSeason == -1 && toEpisode != -1 && episode.EpisodeNumber > toEpisode {
+ continue
+ } else {
+ tmpEps = append(tmpEps, episode)
+ }
+ }
+
+ if len(tmpEps) == 0 {
+ return nil, fmt.Errorf("no episodes are matching the given filter")
+ }
+
+ episodes = tmpEps
+ }
+ }
+
+ localeSorted, err := utils.SortEpisodesByAudio(episodes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get audio locale: %v", err)
+ }
+ for i, locale := range locales {
+ final[i] = append(final[i], localeSorted[locale]...)
+ }
+
+ return final, nil
+}
diff --git a/utils/file.go b/utils/file.go
new file mode 100644
index 0000000..183b06f
--- /dev/null
+++ b/utils/file.go
@@ -0,0 +1,49 @@
+package utils
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+func FreeFileName(filename string) (string, bool) {
+ ext := filepath.Ext(filename)
+ base := strings.TrimSuffix(filename, ext)
+ // checks if a .tar stands before the "actual" file ending
+ if extraExt := filepath.Ext(base); extraExt == ".tar" {
+ ext = extraExt + ext
+ base = strings.TrimSuffix(base, extraExt)
+ }
+ j := 0
+ for ; ; j++ {
+ if _, stat := os.Stat(filename); stat != nil && !os.IsExist(stat) {
+ break
+ }
+ filename = fmt.Sprintf("%s (%d)%s", base, j+1, ext)
+ }
+ return filename, j != 0
+}
+
+func GenerateFilename(name, directory string) string {
+ if runtime.GOOS != "windows" {
+ for _, char := range []string{"/"} {
+ name = strings.ReplaceAll(name, char, "")
+ }
+ Log.Debug("Replaced invalid characters (not windows)")
+ } else {
+ // ahh i love windows :)))
+ for _, char := range []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"} {
+ name = strings.ReplaceAll(name, char, "")
+ }
+ Log.Debug("Replaced invalid characters (windows)")
+ }
+
+ filename, changed := FreeFileName(filepath.Join(directory, name))
+ if changed {
+ Log.Debug("File `%s` already exists, changing name to `%s`", filepath.Base(name), filepath.Base(filename))
+ }
+
+ return filename
+}
diff --git a/utils/format.go b/utils/format.go
new file mode 100644
index 0000000..9642a31
--- /dev/null
+++ b/utils/format.go
@@ -0,0 +1,63 @@
+package utils
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "reflect"
+ "runtime"
+ "strings"
+)
+
+type FormatInformation struct {
+ // the Format to download
+ Format *crunchyroll.Format
+
+ // additional formats which are only used by archive.go
+ AdditionalFormats []*crunchyroll.Format
+
+ Title string `json:"title"`
+ SeriesName string `json:"series_name"`
+ SeasonName string `json:"season_name"`
+ SeasonNumber int `json:"season_number"`
+ EpisodeNumber int `json:"episode_number"`
+ Resolution string `json:"resolution"`
+ FPS float64 `json:"fps"`
+ Audio crunchyroll.LOCALE `json:"audio"`
+ Subtitle crunchyroll.LOCALE `json:"subtitle"`
+}
+
+func (fi FormatInformation) FormatString(source string) string {
+ fields := reflect.TypeOf(fi)
+ values := reflect.ValueOf(fi)
+
+ for i := 0; i < fields.NumField(); i++ {
+ var valueAsString string
+ switch value := values.Field(i); value.Kind() {
+ case reflect.String:
+ valueAsString = value.String()
+ case reflect.Int:
+ valueAsString = fmt.Sprintf("%02d", value.Int())
+ case reflect.Float64:
+ valueAsString = fmt.Sprintf("%.2f", value.Float())
+ case reflect.Bool:
+ valueAsString = fields.Field(i).Tag.Get("json")
+ if !value.Bool() {
+ valueAsString = "no " + valueAsString
+ }
+ }
+
+ if runtime.GOOS != "windows" {
+ for _, char := range []string{"/"} {
+ valueAsString = strings.ReplaceAll(valueAsString, char, "")
+ }
+ } else {
+ for _, char := range []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"} {
+ valueAsString = strings.ReplaceAll(valueAsString, char, "")
+ }
+ }
+
+ source = strings.ReplaceAll(source, "{"+fields.Field(i).Tag.Get("json")+"}", valueAsString)
+ }
+
+ return source
+}
diff --git a/utils/http.go b/utils/http.go
new file mode 100644
index 0000000..f572bd9
--- /dev/null
+++ b/utils/http.go
@@ -0,0 +1,51 @@
+package utils
+
+import (
+ "net/http"
+ "net/url"
+ "time"
+)
+
+type headerRoundTripper struct {
+ http.RoundTripper
+ header map[string]string
+}
+
+func (rht headerRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+ resp, err := rht.RoundTripper.RoundTrip(r)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range rht.header {
+ resp.Header.Set(k, v)
+ }
+ return resp, nil
+}
+
+func CreateOrDefaultClient(proxy, useragent string) (*http.Client, error) {
+ if proxy == "" {
+ return http.DefaultClient, nil
+ } else {
+ proxyURL, err := url.Parse(proxy)
+ if err != nil {
+ return nil, err
+ }
+
+ var rt http.RoundTripper = &http.Transport{
+ DisableCompression: true,
+ Proxy: http.ProxyURL(proxyURL),
+ }
+ if useragent != "" {
+ rt = headerRoundTripper{
+ RoundTripper: rt,
+ header: map[string]string{"User-Agent": useragent},
+ }
+ }
+
+ client := &http.Client{
+ Transport: rt,
+ Timeout: 30 * time.Second,
+ }
+ return client, nil
+ }
+}
diff --git a/utils/locale.go b/utils/locale.go
new file mode 100644
index 0000000..e1517bb
--- /dev/null
+++ b/utils/locale.go
@@ -0,0 +1,59 @@
+package utils
+
+import (
+ "fmt"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "os"
+ "os/exec"
+ "runtime"
+ "sort"
+ "strings"
+)
+
+// SystemLocale receives the system locale
+// https://stackoverflow.com/questions/51829386/golang-get-system-language/51831590#51831590
+func SystemLocale(verbose bool) crunchyroll.LOCALE {
+ if runtime.GOOS != "windows" {
+ if lang, ok := os.LookupEnv("LANG"); ok {
+ var l crunchyroll.LOCALE
+ if preSuffix := strings.Split(strings.Split(lang, ".")[0], "_"); len(preSuffix) == 1 {
+ l = crunchyroll.LOCALE(preSuffix[0])
+ } else {
+ prefix := strings.Split(lang, "_")[0]
+ l = crunchyroll.LOCALE(fmt.Sprintf("%s-%s", prefix, preSuffix[1]))
+ }
+ if !utils.ValidateLocale(l) {
+ if verbose {
+ Log.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US)
+ }
+ l = crunchyroll.US
+ }
+ return l
+ }
+ } else {
+ cmd := exec.Command("powershell", "Get-Culture | select -exp Name")
+ if output, err := cmd.Output(); err == nil {
+ l := crunchyroll.LOCALE(strings.Trim(string(output), "\r\n"))
+ if !utils.ValidateLocale(l) {
+ if verbose {
+ Log.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US)
+ }
+ l = crunchyroll.US
+ }
+ return l
+ }
+ }
+ if verbose {
+ Log.Err("Failed to get locale, using %s", crunchyroll.US)
+ }
+ return crunchyroll.US
+}
+
+func LocalesAsStrings() (locales []string) {
+ for _, locale := range utils.AllLocales {
+ locales = append(locales, string(locale))
+ }
+ sort.Strings(locales)
+ return
+}
diff --git a/utils/logger.go b/utils/logger.go
new file mode 100644
index 0000000..27ea344
--- /dev/null
+++ b/utils/logger.go
@@ -0,0 +1,12 @@
+package utils
+
+type Logger interface {
+ IsDev() bool
+ Debug(format string, v ...any)
+ Info(format string, v ...any)
+ Warn(format string, v ...any)
+ Err(format string, v ...any)
+ Empty()
+ SetProcess(format string, v ...any)
+ StopProcess(format string, v ...any)
+}
diff --git a/utils/save.go b/utils/save.go
new file mode 100644
index 0000000..43eeda7
--- /dev/null
+++ b/utils/save.go
@@ -0,0 +1,177 @@
+package utils
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "fmt"
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func SaveSession(crunchy *crunchyroll.Crunchyroll) error {
+ file := filepath.Join(os.TempDir(), ".crunchy")
+ return os.WriteFile(file, []byte(crunchy.EtpRt), 0600)
+}
+
+func SaveCredentialsPersistent(user, password string, encryptionKey []byte) error {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return err
+ }
+ file := filepath.Join(configDir, "crunchy-cli", "crunchy")
+
+ var credentials []byte
+ if encryptionKey != nil {
+ hashedEncryptionKey := sha256.Sum256(encryptionKey)
+ block, err := aes.NewCipher(hashedEncryptionKey[:])
+ if err != nil {
+ return err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
+ return err
+ }
+ b := gcm.Seal(nonce, nonce, []byte(fmt.Sprintf("%s\n%s", user, password)), nil)
+ credentials = append([]byte("aes:"), b...)
+ } else {
+ credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
+ }
+
+ if err = os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(file, credentials, 0600)
+}
+
+func SaveSessionPersistent(crunchy *crunchyroll.Crunchyroll) error {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return err
+ }
+ file := filepath.Join(configDir, "crunchy-cli", "crunchy")
+
+ if err = os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(file, []byte(crunchy.EtpRt), 0600)
+}
+
+func IsTempSession() bool {
+ file := filepath.Join(os.TempDir(), ".crunchy")
+ if _, err := os.Stat(file); !os.IsNotExist(err) {
+ return true
+ }
+ return false
+}
+
+func IsSavedSessionEncrypted() (bool, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return false, err
+ }
+ file := filepath.Join(configDir, "crunchy-cli", "crunchy")
+ body, err := os.ReadFile(file)
+ if err != nil {
+ return false, err
+ }
+ return strings.HasPrefix(string(body), "aes:"), nil
+}
+
+func LoadSession(encryptionKey []byte) (*crunchyroll.Crunchyroll, error) {
+ file := filepath.Join(os.TempDir(), ".crunchy")
+ crunchy, err := loadTempSession(file)
+ if err != nil {
+ return nil, err
+ }
+ if crunchy != nil {
+ return crunchy, nil
+ }
+
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return nil, err
+ }
+ file = filepath.Join(configDir, "crunchy-cli", "crunchy")
+ crunchy, err = loadPersistentSession(file, encryptionKey)
+ if err != nil {
+ return nil, err
+ }
+ if crunchy != nil {
+ return crunchy, nil
+ }
+
+ return nil, fmt.Errorf("not logged in")
+}
+
+func loadTempSession(file string) (*crunchyroll.Crunchyroll, error) {
+ if _, err := os.Stat(file); !os.IsNotExist(err) {
+ body, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ crunchy, err := crunchyroll.LoginWithEtpRt(string(body), SystemLocale(true), Client)
+ if err != nil {
+ Log.Debug("Failed to login with temp etp rt cookie: %v", err)
+ } else {
+ Log.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
+ return crunchy, nil
+ }
+ }
+ return nil, nil
+}
+
+func loadPersistentSession(file string, encryptionKey []byte) (crunchy *crunchyroll.Crunchyroll, err error) {
+ if _, err = os.Stat(file); !os.IsNotExist(err) {
+ body, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ split := strings.SplitN(string(body), "\n", 2)
+ if len(split) == 1 || split[1] == "" && strings.HasPrefix(split[0], "aes:") {
+ encrypted := body[4:]
+ hashedEncryptionKey := sha256.Sum256(encryptionKey)
+ block, err := aes.NewCipher(hashedEncryptionKey[:])
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ nonce, cypherText := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():]
+ b, err := gcm.Open(nil, nonce, cypherText, nil)
+ if err != nil {
+ return nil, err
+ }
+ split = strings.SplitN(string(b), "\n", 2)
+ }
+ if len(split) == 2 {
+ if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], SystemLocale(true), Client); err != nil {
+ return nil, err
+ }
+ Log.Debug("Logged in with credentials")
+ } else {
+ if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], SystemLocale(true), Client); err != nil {
+ return nil, err
+ }
+ Log.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
+ }
+
+ // the etp rt is written to a temp file to reduce the amount of re-logging in.
+ // it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600); err != nil {
+ return nil, err
+ }
+ }
+
+ return
+}
diff --git a/utils/system.go b/utils/system.go
new file mode 100644
index 0000000..ac97706
--- /dev/null
+++ b/utils/system.go
@@ -0,0 +1,7 @@
+package utils
+
+import "os/exec"
+
+func HasFFmpeg() bool {
+ return exec.Command("ffmpeg", "-h").Run() == nil
+}
diff --git a/utils/vars.go b/utils/vars.go
new file mode 100644
index 0000000..caca63e
--- /dev/null
+++ b/utils/vars.go
@@ -0,0 +1,14 @@
+package utils
+
+import (
+ "github.com/ByteDream/crunchyroll-go/v3"
+ "net/http"
+)
+
+var Version = "development"
+
+var (
+ Crunchy *crunchyroll.Crunchyroll
+ Client *http.Client
+ Log Logger
+)
From 5b4c228b60cae297760f6401ad8c6ea6ebc84ebc Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 25 Jul 2022 10:53:29 +0200
Subject: [PATCH 76/87] Change crunchyroll-go dependency name
---
cli/commands/archive/archive.go | 4 ++--
cli/commands/download/download.go | 4 ++--
cli/commands/info/info.go | 2 +-
cli/commands/login/login.go | 2 +-
go.mod | 2 +-
go.sum | 6 ++----
utils/extract.go | 4 ++--
utils/format.go | 2 +-
utils/locale.go | 4 ++--
utils/save.go | 2 +-
utils/vars.go | 2 +-
11 files changed, 16 insertions(+), 18 deletions(-)
diff --git a/cli/commands/archive/archive.go b/cli/commands/archive/archive.go
index 29ee836..99cb77f 100644
--- a/cli/commands/archive/archive.go
+++ b/cli/commands/archive/archive.go
@@ -7,8 +7,8 @@ import (
"fmt"
"github.com/ByteDream/crunchy-cli/cli/commands"
"github.com/ByteDream/crunchy-cli/utils"
- "github.com/ByteDream/crunchyroll-go/v3"
- crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
+ crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"io"
diff --git a/cli/commands/download/download.go b/cli/commands/download/download.go
index 4e0ef66..2f2ec9f 100644
--- a/cli/commands/download/download.go
+++ b/cli/commands/download/download.go
@@ -5,8 +5,8 @@ import (
"fmt"
"github.com/ByteDream/crunchy-cli/cli/commands"
"github.com/ByteDream/crunchy-cli/utils"
- "github.com/ByteDream/crunchyroll-go/v3"
- crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
+ crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"math"
diff --git a/cli/commands/info/info.go b/cli/commands/info/info.go
index a9a6ef0..9b816fd 100644
--- a/cli/commands/info/info.go
+++ b/cli/commands/info/info.go
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/ByteDream/crunchy-cli/cli/commands"
"github.com/ByteDream/crunchy-cli/utils"
- crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
+ crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/spf13/cobra"
)
diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go
index 31155d8..7fb135c 100644
--- a/cli/commands/login/login.go
+++ b/cli/commands/login/login.go
@@ -5,7 +5,7 @@ import (
"fmt"
"github.com/ByteDream/crunchy-cli/cli/commands"
"github.com/ByteDream/crunchy-cli/utils"
- "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
"github.com/spf13/cobra"
"os"
)
diff --git a/go.mod b/go.mod
index 909378c..ce09935 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/ByteDream/crunchy-cli
go 1.18
require (
- github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1
+ github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.5.0
)
diff --git a/go.sum b/go.sum
index 5603301..34683e1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,6 @@
-github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY=
-github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
-github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1 h1:hOL4xzDc6oCcrpf6GrrdUgvqwsQo6dI2zL4nA8rl9hg=
-github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd h1:wxMjLwhvilxwTnYCOXmBPenMNymhC63m4aFzQ5C7ipQ=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd/go.mod h1:SjTQD3IX7Z+MLsMSd2fP5ttsJ4KtpXY6r08bHLwrOLM=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
diff --git a/utils/extract.go b/utils/extract.go
index ba52242..60ced1d 100644
--- a/utils/extract.go
+++ b/utils/extract.go
@@ -2,8 +2,8 @@ package utils
import (
"fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"regexp"
"strconv"
"strings"
diff --git a/utils/format.go b/utils/format.go
index 9642a31..6d40142 100644
--- a/utils/format.go
+++ b/utils/format.go
@@ -2,7 +2,7 @@ package utils
import (
"fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
"reflect"
"runtime"
"strings"
diff --git a/utils/locale.go b/utils/locale.go
index e1517bb..9769940 100644
--- a/utils/locale.go
+++ b/utils/locale.go
@@ -2,8 +2,8 @@ package utils
import (
"fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
- "github.com/ByteDream/crunchyroll-go/v3/utils"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"os"
"os/exec"
"runtime"
diff --git a/utils/save.go b/utils/save.go
index 43eeda7..ef6d0ec 100644
--- a/utils/save.go
+++ b/utils/save.go
@@ -6,7 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"fmt"
- "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
"io"
"os"
"path/filepath"
diff --git a/utils/vars.go b/utils/vars.go
index caca63e..d2893a5 100644
--- a/utils/vars.go
+++ b/utils/vars.go
@@ -1,7 +1,7 @@
package utils
import (
- "github.com/ByteDream/crunchyroll-go/v3"
+ "github.com/crunchy-labs/crunchyroll-go/v3"
"net/http"
)
From 2773445050c45d262d18834c9b1f9cf2394e524a Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 26 Jul 2022 12:47:23 +0200
Subject: [PATCH 77/87] Change author names and links to crunchy-labs
---
Makefile | 8 +++----
README.md | 40 +++++++++++++++----------------
cli/commands/archive/archive.go | 6 ++---
cli/commands/archive/compress.go | 2 +-
cli/commands/download/download.go | 6 ++---
cli/commands/info/info.go | 4 ++--
cli/commands/logger.go | 2 +-
cli/commands/login/login.go | 4 ++--
cli/commands/update/update.go | 12 +++++-----
cli/commands/utils.go | 2 +-
cli/root.go | 16 ++++++-------
crunchy-cli.1 | 6 ++---
go.mod | 2 +-
main.go | 2 +-
14 files changed, 56 insertions(+), 56 deletions(-)
diff --git a/Makefile b/Makefile
index 7836af9..966872b 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ DESTDIR=
PREFIX=/usr
build:
- go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(BINARY_NAME) .
+ go build -ldflags "-X 'github.com/crunchy-labs/crunchy-cli/utils.Version=$(VERSION)'" -o $(BINARY_NAME) .
clean:
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
@@ -24,8 +24,8 @@ uninstall:
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
release:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
- CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/crunchy-labs/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
+ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/crunchy-labs/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
+ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/crunchy-labs/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
strip $(VERSION_BINARY_NAME)_linux
diff --git a/README.md b/README.md
index bce05b9..09cd829 100644
--- a/README.md
+++ b/README.md
@@ -3,26 +3,26 @@
A [go](https://golang.org) written cli client for [crunchyroll](https://www.crunchyroll.com). To use it, you need a crunchyroll premium account to for full (api) access.
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
@@ -47,10 +47,10 @@ See #39 for more information._
## 💾 Get the executable
-- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchy-cli/releases/latest) or get it from below:
- - [Linux (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_linux)
- - [Windows (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_windows.exe)
- - [MacOS (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_darwin)
+- 📥 Download the latest binaries [here](https://github.com/crunchy-labs/crunchy-cli/releases/latest) or get it from below:
+ - [Linux (x64)](https://smartrelease.crunchy-labs.org/github/crunchy-labs/crunchy-cli/crunchy-{tag}_linux)
+ - [Windows (x64)](https://smartrelease.crunchy-labs.org/github/crunchy-labs/crunchy-cli/crunchy-{tag}_windows.exe)
+ - [MacOS (x64)](https://smartrelease.crunchy-labs.org/github/crunchy-labs/crunchy-cli/crunchy-{tag}_darwin)
- If you use Arch btw. or any other Linux distro which is based on Arch Linux, you can download the package via the [AUR](https://aur.archlinux.org/packages/crunchyroll-go/):
```shell
$ yay -S crunchyroll-go
@@ -65,24 +65,24 @@ See #39 for more information._
Currently not working because the repo got renamed!
-- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/ByteDream/crunchy-cli/releases/latest) (like Raspberry Pi or M1 Mac):
+- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/crunchy-labs/crunchy-cli/releases/latest) (like Raspberry Pi or M1 Mac):
- use `make` (requires `go` to be installed):
```shell
- $ git clone https://github.com/ByteDream/crunchy-cli
+ $ git clone https://github.com/crunchy-labs/crunchy-cli
$ cd crunchy-cli
$ make
$ sudo make install # <- only if you want to install it on your system
```
- use `go`:
```shell
- $ git clone https://github.com/ByteDream/crunchy-cli
+ $ git clone https://github.com/crunchy-labs/crunchy-cli
$ cd crunchy-cli
$ go build -o crunchy .
```
## 📝 Examples
-_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli), further usages and options are described there.
+_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/crunchy-labs/crunchy-cli/wiki/Cli), further usages and options are described there.
### Login
@@ -169,7 +169,7 @@ The following flags can be (optional) passed to modify the [archive](#archive) p
| `-l` | `--language` | Audio locale which should be downloaded. Can be used multiple times. |
| `-d` | `--directory` | Directory to download the video(s) to. |
| `-o` | `--output` | Name of the output file. |
-| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli#archive) for more information. |
+| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/crunchy-labs/crunchy-cli/wiki/Cli#archive) for more information. |
| `-c` | `--compress` | If is set, all output will be compresses into an archive. This flag sets the name of the compressed output file and the file ending specifies the compression algorithm (gzip, tar, zip are supported). |
| `-r` | `--resolution` | The resolution of the video(s). `best` for best resolution, `worst` for worst. |
| `-g` | `--goroutines` | Sets how many parallel segment downloads should be used. |
diff --git a/cli/commands/archive/archive.go b/cli/commands/archive/archive.go
index 99cb77f..8e09f5e 100644
--- a/cli/commands/archive/archive.go
+++ b/cli/commands/archive/archive.go
@@ -5,8 +5,8 @@ import (
"bytes"
"context"
"fmt"
- "github.com/ByteDream/crunchy-cli/cli/commands"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"github.com/crunchy-labs/crunchyroll-go/v3"
crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
@@ -185,7 +185,7 @@ func archive(urls []string) error {
utils.Log.StopProcess("Failed to parse url %d", i+1)
if utils.Crunchy.Config.Premium {
utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/crunchy-labs/crunchy-cli/issues/22 for more information")
}
return err
}
diff --git a/cli/commands/archive/compress.go b/cli/commands/archive/compress.go
index d6aa492..e0b9ad4 100644
--- a/cli/commands/archive/compress.go
+++ b/cli/commands/archive/compress.go
@@ -6,7 +6,7 @@ import (
"bytes"
"compress/gzip"
"fmt"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"io"
"os"
"path/filepath"
diff --git a/cli/commands/download/download.go b/cli/commands/download/download.go
index 2f2ec9f..2976e2d 100644
--- a/cli/commands/download/download.go
+++ b/cli/commands/download/download.go
@@ -3,8 +3,8 @@ package download
import (
"context"
"fmt"
- "github.com/ByteDream/crunchy-cli/cli/commands"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"github.com/crunchy-labs/crunchyroll-go/v3"
crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8"
@@ -136,7 +136,7 @@ func download(urls []string) error {
utils.Log.StopProcess("Failed to parse url %d", i+1)
if utils.Crunchy.Config.Premium {
utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
- "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
+ "try the corresponding crunchyroll beta url instead and try again. See https://github.com/crunchy-labs/crunchy-cli/issues/22 for more information")
}
return err
}
diff --git a/cli/commands/info/info.go b/cli/commands/info/info.go
index 9b816fd..db909c1 100644
--- a/cli/commands/info/info.go
+++ b/cli/commands/info/info.go
@@ -2,8 +2,8 @@ package info
import (
"fmt"
- "github.com/ByteDream/crunchy-cli/cli/commands"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands"
+ "github.com/crunchy-labs/crunchy-cli/utils"
crunchyUtils "github.com/crunchy-labs/crunchyroll-go/v3/utils"
"github.com/spf13/cobra"
)
diff --git a/cli/commands/logger.go b/cli/commands/logger.go
index 2f3ae37..89a4717 100644
--- a/cli/commands/logger.go
+++ b/cli/commands/logger.go
@@ -2,7 +2,7 @@ package commands
import (
"fmt"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"io"
"log"
"os"
diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go
index 7fb135c..eb1b4dc 100644
--- a/cli/commands/login/login.go
+++ b/cli/commands/login/login.go
@@ -3,8 +3,8 @@ package login
import (
"bytes"
"fmt"
- "github.com/ByteDream/crunchy-cli/cli/commands"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"github.com/crunchy-labs/crunchyroll-go/v3"
"github.com/spf13/cobra"
"os"
diff --git a/cli/commands/update/update.go b/cli/commands/update/update.go
index 6587ca2..831621d 100644
--- a/cli/commands/update/update.go
+++ b/cli/commands/update/update.go
@@ -3,7 +3,7 @@ package update
import (
"encoding/json"
"fmt"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"github.com/spf13/cobra"
"io"
"os"
@@ -37,7 +37,7 @@ func init() {
func update() error {
var release map[string]interface{}
- resp, err := utils.Client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
+ resp, err := utils.Client.Get("https://api.github.com/repos/crunchy-labs/crunchy-cli/releases/latest")
if err != nil {
return err
}
@@ -79,12 +79,12 @@ func update() error {
return nil
}
- utils.Log.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
+ utils.Log.Info("A new version is available (%s): https://github.com/crunchy-labs/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
if updateInstallFlag {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
- "You have to update manually (https://github.com/ByteDream/crunchy-cli)", runtime.GOARCH)
+ "You have to update manually (https://github.com/crunchy-labs/crunchy-cli)", runtime.GOARCH)
}
var downloadFile string
@@ -102,7 +102,7 @@ func update() error {
downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
default:
return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
- "You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS)
+ "You have to update manually (https://github.com/crunchy-labs/crunchy-cli", runtime.GOOS)
}
utils.Log.SetProcess("Updating executable %s", os.Args[0])
@@ -118,7 +118,7 @@ func update() error {
}
defer executeFile.Close()
- resp, err := utils.Client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
+ resp, err := utils.Client.Get(fmt.Sprintf("https://github.com/crunchy-labs/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
if err != nil {
return err
}
diff --git a/cli/commands/utils.go b/cli/commands/utils.go
index 924cd4d..53e51f2 100644
--- a/cli/commands/utils.go
+++ b/cli/commands/utils.go
@@ -2,7 +2,7 @@ package commands
import (
"fmt"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"os"
"os/exec"
"runtime"
diff --git a/cli/root.go b/cli/root.go
index ebb5079..b589a03 100644
--- a/cli/root.go
+++ b/cli/root.go
@@ -3,13 +3,13 @@ package cli
import (
"context"
"fmt"
- "github.com/ByteDream/crunchy-cli/cli/commands"
- "github.com/ByteDream/crunchy-cli/cli/commands/archive"
- "github.com/ByteDream/crunchy-cli/cli/commands/download"
- "github.com/ByteDream/crunchy-cli/cli/commands/info"
- "github.com/ByteDream/crunchy-cli/cli/commands/login"
- "github.com/ByteDream/crunchy-cli/cli/commands/update"
- "github.com/ByteDream/crunchy-cli/utils"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands/archive"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands/download"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands/info"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands/login"
+ "github.com/crunchy-labs/crunchy-cli/cli/commands/update"
+ "github.com/crunchy-labs/crunchy-cli/utils"
"github.com/spf13/cobra"
"os"
"runtime/debug"
@@ -28,7 +28,7 @@ var (
var RootCmd = &cobra.Command{
Use: "crunchy-cli",
Version: utils.Version,
- Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki",
+ Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/crunchy-labs/crunchy-cli/wiki",
SilenceErrors: true,
SilenceUsage: true,
diff --git a/crunchy-cli.1 b/crunchy-cli.1
index ebadb9c..6f20dba 100644
--- a/crunchy-cli.1
+++ b/crunchy-cli.1
@@ -193,12 +193,12 @@ $ crunchy-cli archive -c "ditf.tar.gz" https://beta.crunchyroll.com/series/GY8VE
If you notice any bug or want an enhancement, feel free to create a new issue or pull request in the GitHub repository.
.SH AUTHOR
-ByteDream
+Crunchy Labs Maintainers
.br
-Source: https://github.com/ByteDream/crunchy-cli
+Source: https://github.com/crunchy-labs/crunchy-cli
.SH COPYRIGHT
-Copyright (C) 2022 ByteDream
+Copyright (C) 2022 Crunchy Labs Maintainers
This program is free software: you can redistribute it and/or
modify it under the terms of the GNU General Public License
diff --git a/go.mod b/go.mod
index ce09935..37a4e42 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/ByteDream/crunchy-cli
+module github.com/crunchy-labs/crunchy-cli
go 1.18
diff --git a/main.go b/main.go
index 7275a13..72ec83b 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,7 @@
package main
import (
- "github.com/ByteDream/crunchy-cli/cli"
+ "github.com/crunchy-labs/crunchy-cli/cli"
)
func main() {
From 8942ea574bf1bf76ba59cfa3fa8e2e768625b192 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 27 Jul 2022 21:25:03 +0200
Subject: [PATCH 78/87] Add v3 notice
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 09cd829..e8a363d 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,10 @@ A [go](https://golang.org) written cli client for [crunchyroll](https://www.crun
_This repo was former known as **crunchyroll-go** (which still exists but now contains only the library part) but got split up into two separate repositories to provide more flexibility.
See #39 for more information._
+> This tool relies on the [crunchyroll-go](https://github.com/crunchy-labs/crunchyroll-go) library to communicate with crunchyroll.
+> The library enters maintenance mode (only small fixes, no new features) with version v3 in favor of rewriting it completely in Rust.
+> **crunchy-cli** follows it (also beginning with version v3) and won't have major updates until the Rust rewrite of the library reaches a good usable state.
+
# 🖥️ CLI
## ✨ Features
From b62769ccfd90d66f4774577337e729c8bea0d940 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Wed, 27 Jul 2022 21:53:03 +0200
Subject: [PATCH 79/87] Update next version number
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e8a363d..85e1bf5 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ See #39 for more information._
> This tool relies on the [crunchyroll-go](https://github.com/crunchy-labs/crunchyroll-go) library to communicate with crunchyroll.
> The library enters maintenance mode (only small fixes, no new features) with version v3 in favor of rewriting it completely in Rust.
-> **crunchy-cli** follows it (also beginning with version v3) and won't have major updates until the Rust rewrite of the library reaches a good usable state.
+> **crunchy-cli** follows it (with version v2.3.0) and won't have major updates until the Rust rewrite of the library reaches a good usable state.
# 🖥️ CLI
From fbb90f907953546ab5282f295a401bda9d6babc7 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Fri, 29 Jul 2022 18:23:38 +0200
Subject: [PATCH 80/87] Fix info spacing
---
cli/commands/info/info.go | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/cli/commands/info/info.go b/cli/commands/info/info.go
index db909c1..650d8c7 100644
--- a/cli/commands/info/info.go
+++ b/cli/commands/info/info.go
@@ -28,13 +28,13 @@ func info() error {
return err
}
- fmt.Println("Username: ", account.Username)
- fmt.Println("Email: ", account.Email)
- fmt.Println("Premium: ", utils.Crunchy.Config.Premium)
- fmt.Println("Interface language:", crunchyUtils.LocaleLanguage(account.PreferredCommunicationLanguage))
- fmt.Println("Subtitle language: ", crunchyUtils.LocaleLanguage(account.PreferredContentSubtitleLanguage))
- fmt.Println("Created: ", account.Created)
- fmt.Println("Account ID: ", account.AccountID)
+ fmt.Println("Username: ", account.Username)
+ fmt.Println("Email: ", account.Email)
+ fmt.Println("Premium: ", utils.Crunchy.Config.Premium)
+ fmt.Println("Interface language: ", crunchyUtils.LocaleLanguage(account.PreferredCommunicationLanguage))
+ fmt.Println("Subtitle language: ", crunchyUtils.LocaleLanguage(account.PreferredContentSubtitleLanguage))
+ fmt.Println("Created: ", account.Created)
+ fmt.Println("Account ID: ", account.AccountID)
return nil
}
From 81946c5092f53a37f5d4ceeca7af370bcc277fb5 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 31 Jul 2022 13:50:59 +0200
Subject: [PATCH 81/87] Remove CI badge
---
README.md | 3 ---
1 file changed, 3 deletions(-)
diff --git a/README.md b/README.md
index 85e1bf5..1147f1e 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,6 @@ A [go](https://golang.org) written cli client for [crunchyroll](https://www.crun
-
-
-
From caeb734b2c03fa432e8395ff93c65d3d3f3f7a71 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 1 Aug 2022 00:41:09 +0200
Subject: [PATCH 82/87] Add login session id warning
---
cli/commands/login/login.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go
index eb1b4dc..20ceb6a 100644
--- a/cli/commands/login/login.go
+++ b/cli/commands/login/login.go
@@ -108,6 +108,7 @@ func loginCredentials(user, password string) error {
func loginSessionID(sessionID string) error {
utils.Log.Debug("Logging in via session id")
+ utils.Log.Warn("Logging in with session id is deprecated and not very reliable. Consider choosing another option (if it fails)")
var c *crunchyroll.Crunchyroll
var err error
if c, err = crunchyroll.LoginWithSessionID(sessionID, utils.SystemLocale(false), utils.Client); err != nil {
From a64981930b3e08629752490b2fc31b85e1cc2dce Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 2 Aug 2022 12:07:43 +0200
Subject: [PATCH 83/87] Add option to change temp dir
---
cli/commands/archive/archive.go | 7 +++++++
cli/commands/download/download.go | 7 +++++++
2 files changed, 14 insertions(+)
diff --git a/cli/commands/archive/archive.go b/cli/commands/archive/archive.go
index 8e09f5e..b2be970 100644
--- a/cli/commands/archive/archive.go
+++ b/cli/commands/archive/archive.go
@@ -29,6 +29,7 @@ var (
archiveDirectoryFlag string
archiveOutputFlag string
+ archiveTempDirFlag string
archiveMergeFlag string
@@ -146,6 +147,10 @@ func init() {
"\t{fps} » Frame Rate of the video\n"+
"\t{audio} » Audio locale of the video\n"+
"\t{subtitle} » Subtitle locale of the video")
+ Cmd.Flags().StringVar(&archiveTempDirFlag,
+ "temp",
+ os.TempDir(),
+ "Directory to store temporary files in")
Cmd.Flags().StringVarP(&archiveMergeFlag,
"merge",
@@ -315,6 +320,8 @@ func archiveInfo(info utils.FormatInformation, writeCloser io.WriteCloser, filen
}
return nil
})
+ tmp, _ := os.MkdirTemp(archiveTempDirFlag, "crunchy_")
+ downloader.TempDir = tmp
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
diff --git a/cli/commands/download/download.go b/cli/commands/download/download.go
index 2976e2d..60e975e 100644
--- a/cli/commands/download/download.go
+++ b/cli/commands/download/download.go
@@ -25,6 +25,7 @@ var (
downloadDirectoryFlag string
downloadOutputFlag string
+ downloadTempDirFlag string
downloadResolutionFlag string
@@ -111,6 +112,10 @@ func init() {
"\t{fps} » Frame Rate of the video\n"+
"\t{audio} » Audio locale of the video\n"+
"\t{subtitle} » Subtitle locale of the video")
+ Cmd.Flags().StringVar(&downloadTempDirFlag,
+ "temp",
+ os.TempDir(),
+ "Directory to store temporary files in")
Cmd.Flags().StringVarP(&downloadResolutionFlag,
"resolution",
@@ -230,6 +235,8 @@ func downloadInfo(info utils.FormatInformation, file *os.File) error {
}
return nil
})
+ tmp, _ := os.MkdirTemp(downloadTempDirFlag, "crunchy_")
+ downloader.TempDir = tmp
if utils.HasFFmpeg() {
downloader.FFmpegOpts = make([]string, 0)
}
From 6239d10d220b947cd0c0370996832b522f4d0ca0 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Mon, 8 Aug 2022 22:20:25 +0200
Subject: [PATCH 84/87] Fix crunchyroll api changes
---
cli/commands/archive/archive.go | 6 ++++
cli/commands/download/download.go | 52 +++----------------------------
utils/extract.go | 19 ++++++-----
3 files changed, 22 insertions(+), 55 deletions(-)
diff --git a/cli/commands/archive/archive.go b/cli/commands/archive/archive.go
index b2be970..a15822c 100644
--- a/cli/commands/archive/archive.go
+++ b/cli/commands/archive/archive.go
@@ -627,6 +627,12 @@ func archiveExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
}
}
+ if _, ok := crunchyroll.ParseBetaEpisodeURL(url); ok {
+ return nil, fmt.Errorf("archiving episodes by url is no longer supported (thx crunchyroll). use the series url instead and filter after the given episode (https://github.com/crunchy-labs/crunchy-cli/wiki/Cli#filter)")
+ } else if _, _, _, _, ok := crunchyroll.ParseEpisodeURL(url); ok {
+ return nil, fmt.Errorf("archiving episodes by url is no longer supported (thx crunchyroll). use the series url instead and filter after the given episode (https://github.com/crunchy-labs/crunchy-cli/wiki/Cli#filter)")
+ }
+
episodes, err := utils.ExtractEpisodes(url, languagesAsLocale...)
if err != nil {
return nil, err
diff --git a/cli/commands/download/download.go b/cli/commands/download/download.go
index 60e975e..b7f3153 100644
--- a/cli/commands/download/download.go
+++ b/cli/commands/download/download.go
@@ -14,13 +14,11 @@ import (
"os/signal"
"path/filepath"
"runtime"
- "sort"
"strconv"
"strings"
)
var (
- downloadAudioFlag string
downloadSubtitleFlag string
downloadDirectoryFlag string
@@ -49,12 +47,10 @@ var Cmd = &cobra.Command{
}
}
- if !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) {
- return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(utils.LocalesAsStrings(), ", "))
- } else if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
+ if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(utils.LocalesAsStrings(), ", "))
}
- utils.Log.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
+ utils.Log.Debug("Subtitle locale: %s", downloadSubtitleFlag)
switch downloadResolutionFlag {
case "1080p", "720p", "480p", "360p":
@@ -81,10 +77,6 @@ var Cmd = &cobra.Command{
}
func init() {
- Cmd.Flags().StringVarP(&downloadAudioFlag, "audio",
- "a",
- string(utils.SystemLocale(false)),
- "The locale of the audio. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", "))
Cmd.Flags().StringVarP(&downloadSubtitleFlag,
"subtitle",
"s",
@@ -280,49 +272,13 @@ func downloadInfo(info utils.FormatInformation, file *os.File) error {
}
func downloadExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
- episodes, err := utils.ExtractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag))
+ episodes, err := utils.ExtractEpisodes(url)
if err != nil {
return nil, err
}
- japanese := episodes[0]
- custom := episodes[1]
-
- sort.Sort(crunchyUtils.EpisodesByNumber(japanese))
- sort.Sort(crunchyUtils.EpisodesByNumber(custom))
-
- var errMessages []string
-
- var final []*crunchyroll.Episode
- if len(japanese) == 0 || len(japanese) == len(custom) {
- final = custom
- } else {
- for _, jp := range japanese {
- before := len(final)
- for _, episode := range custom {
- if jp.SeasonNumber == episode.SeasonNumber && jp.EpisodeNumber == episode.EpisodeNumber {
- final = append(final, episode)
- }
- }
- if before == len(final) {
- errMessages = append(errMessages, fmt.Sprintf("%s has no %s audio, using %s as fallback", jp.Title, crunchyroll.LOCALE(downloadAudioFlag), crunchyroll.JP))
- final = append(final, jp)
- }
- }
- }
-
- if len(errMessages) > 10 {
- for _, msg := range errMessages[:10] {
- utils.Log.SetProcess(msg)
- }
- utils.Log.SetProcess("... and %d more", len(errMessages)-10)
- } else {
- for _, msg := range errMessages {
- utils.Log.SetProcess(msg)
- }
- }
var infoFormat [][]utils.FormatInformation
- for _, season := range crunchyUtils.SortEpisodesBySeason(final) {
+ for _, season := range crunchyUtils.SortEpisodesBySeason(episodes[0]) {
tmpFormatInformation := make([]utils.FormatInformation, 0)
for _, episode := range season {
format, err := episode.GetFormat(downloadResolutionFlag, crunchyroll.LOCALE(downloadSubtitleFlag), true)
diff --git a/utils/extract.go b/utils/extract.go
index 60ced1d..fa2d650 100644
--- a/utils/extract.go
+++ b/utils/extract.go
@@ -28,7 +28,6 @@ func ExtractEpisodes(url string, locales ...crunchyroll.LOCALE) ([][]*crunchyrol
url = url[:lastOpen]
}
- final := make([][]*crunchyroll.Episode, len(locales))
episodes, err := Crunchy.ExtractEpisodesFromUrl(url, locales...)
if err != nil {
return nil, fmt.Errorf("failed to get episodes: %v", err)
@@ -82,12 +81,18 @@ func ExtractEpisodes(url string, locales ...crunchyroll.LOCALE) ([][]*crunchyrol
}
}
- localeSorted, err := utils.SortEpisodesByAudio(episodes)
- if err != nil {
- return nil, fmt.Errorf("failed to get audio locale: %v", err)
- }
- for i, locale := range locales {
- final[i] = append(final[i], localeSorted[locale]...)
+ var final [][]*crunchyroll.Episode
+ if len(locales) > 0 {
+ final = make([][]*crunchyroll.Episode, len(locales))
+ localeSorted, err := utils.SortEpisodesByAudio(episodes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get audio locale: %v", err)
+ }
+ for i, locale := range locales {
+ final[i] = append(final[i], localeSorted[locale]...)
+ }
+ } else {
+ final = [][]*crunchyroll.Episode{episodes}
}
return final, nil
From f7a21fbfb2806779b975779afb70724333e357cc Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 9 Aug 2022 01:04:46 +0200
Subject: [PATCH 85/87] Change all etp rt related stuff to refresh token
---
cli/commands/login/login.go | 22 +++++++++++-----------
go.mod | 2 +-
go.sum | 4 ++--
utils/save.go | 18 +++++++++---------
4 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go
index 20ceb6a..9fce181 100644
--- a/cli/commands/login/login.go
+++ b/cli/commands/login/login.go
@@ -14,8 +14,8 @@ var (
loginPersistentFlag bool
loginEncryptFlag bool
- loginSessionIDFlag bool
- loginEtpRtFlag bool
+ loginSessionIDFlag bool
+ loginRefreshTokenFlag bool
)
var Cmd = &cobra.Command{
@@ -26,8 +26,8 @@ var Cmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
if loginSessionIDFlag {
return loginSessionID(args[0])
- } else if loginEtpRtFlag {
- return loginEtpRt(args[0])
+ } else if loginRefreshTokenFlag {
+ return loginRefreshToken(args[0])
} else {
return loginCredentials(args[0], args[1])
}
@@ -48,12 +48,12 @@ func init() {
"session-id",
false,
"Use a session id to login instead of username and password")
- Cmd.Flags().BoolVar(&loginEtpRtFlag,
- "etp-rt",
+ Cmd.Flags().BoolVar(&loginRefreshTokenFlag,
+ "refresh-token",
false,
- "Use a etp rt cookie to login instead of username and password")
+ "Use a refresh token to login instead of username and password. Can be obtained by copying the `etp-rt` cookie from beta.crunchyroll.com")
- Cmd.MarkFlagsMutuallyExclusive("session-id", "etp-rt")
+ Cmd.MarkFlagsMutuallyExclusive("session-id", "refresh-token")
}
func loginCredentials(user, password string) error {
@@ -132,11 +132,11 @@ func loginSessionID(sessionID string) error {
return nil
}
-func loginEtpRt(etpRt string) error {
- utils.Log.Debug("Logging in via etp rt")
+func loginRefreshToken(refreshToken string) error {
+ utils.Log.Debug("Logging in via refresh token")
var c *crunchyroll.Crunchyroll
var err error
- if c, err = crunchyroll.LoginWithEtpRt(etpRt, utils.SystemLocale(false), utils.Client); err != nil {
+ if c, err = crunchyroll.LoginWithRefreshToken(refreshToken, utils.SystemLocale(false), utils.Client); err != nil {
utils.Log.Err(err.Error())
os.Exit(1)
}
diff --git a/go.mod b/go.mod
index 37a4e42..d7f0eba 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/crunchy-labs/crunchy-cli
go 1.18
require (
- github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd
+ github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.5.0
)
diff --git a/go.sum b/go.sum
index 34683e1..225fce4 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd h1:wxMjLwhvilxwTnYCOXmBPenMNymhC63m4aFzQ5C7ipQ=
-github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220724174415-375eaf9007bd/go.mod h1:SjTQD3IX7Z+MLsMSd2fP5ttsJ4KtpXY6r08bHLwrOLM=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a h1:q5M2xmCTu2njig5rlRAd83LJDaPANmweFJjYsnxi5zM=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a/go.mod h1:SjTQD3IX7Z+MLsMSd2fP5ttsJ4KtpXY6r08bHLwrOLM=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
diff --git a/utils/save.go b/utils/save.go
index ef6d0ec..7660974 100644
--- a/utils/save.go
+++ b/utils/save.go
@@ -15,7 +15,7 @@ import (
func SaveSession(crunchy *crunchyroll.Crunchyroll) error {
file := filepath.Join(os.TempDir(), ".crunchy")
- return os.WriteFile(file, []byte(crunchy.EtpRt), 0600)
+ return os.WriteFile(file, []byte(crunchy.RefreshToken), 0600)
}
func SaveCredentialsPersistent(user, password string, encryptionKey []byte) error {
@@ -62,7 +62,7 @@ func SaveSessionPersistent(crunchy *crunchyroll.Crunchyroll) error {
if err = os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755); err != nil {
return err
}
- return os.WriteFile(file, []byte(crunchy.EtpRt), 0600)
+ return os.WriteFile(file, []byte(crunchy.RefreshToken), 0600)
}
func IsTempSession() bool {
@@ -118,11 +118,11 @@ func loadTempSession(file string) (*crunchyroll.Crunchyroll, error) {
if err != nil {
return nil, err
}
- crunchy, err := crunchyroll.LoginWithEtpRt(string(body), SystemLocale(true), Client)
+ crunchy, err := crunchyroll.LoginWithRefreshToken(string(body), SystemLocale(true), Client)
if err != nil {
- Log.Debug("Failed to login with temp etp rt cookie: %v", err)
+ Log.Debug("Failed to login with temp refresh token: %v", err)
} else {
- Log.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
+ Log.Debug("Logged in with refresh token %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
return crunchy, nil
}
}
@@ -160,15 +160,15 @@ func loadPersistentSession(file string, encryptionKey []byte) (crunchy *crunchyr
}
Log.Debug("Logged in with credentials")
} else {
- if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], SystemLocale(true), Client); err != nil {
+ if crunchy, err = crunchyroll.LoginWithRefreshToken(split[0], SystemLocale(true), Client); err != nil {
return nil, err
}
- Log.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
+ Log.Debug("Logged in with refresh token %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
}
- // the etp rt is written to a temp file to reduce the amount of re-logging in.
+ // the refresh token is written to a temp file to reduce the amount of re-logging in.
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
- if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600); err != nil {
+ if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.RefreshToken), 0600); err != nil {
return nil, err
}
}
From 441ec084af98ee21f4ed447411ca045c0b64dc8b Mon Sep 17 00:00:00 2001
From: bytedream
Date: Tue, 9 Aug 2022 01:20:34 +0200
Subject: [PATCH 86/87] Re-enable language choosing for series
---
cli/commands/download/download.go | 76 ++++++++++++++++++++++++++++---
1 file changed, 70 insertions(+), 6 deletions(-)
diff --git a/cli/commands/download/download.go b/cli/commands/download/download.go
index b7f3153..39273d0 100644
--- a/cli/commands/download/download.go
+++ b/cli/commands/download/download.go
@@ -14,11 +14,13 @@ import (
"os/signal"
"path/filepath"
"runtime"
+ "sort"
"strconv"
"strings"
)
var (
+ downloadAudioFlag string
downloadSubtitleFlag string
downloadDirectoryFlag string
@@ -47,10 +49,12 @@ var Cmd = &cobra.Command{
}
}
- if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
+ if downloadAudioFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) {
+ return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(utils.LocalesAsStrings(), ", "))
+ } else if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) {
return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(utils.LocalesAsStrings(), ", "))
}
- utils.Log.Debug("Subtitle locale: %s", downloadSubtitleFlag)
+ utils.Log.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
switch downloadResolutionFlag {
case "1080p", "720p", "480p", "360p":
@@ -77,6 +81,10 @@ var Cmd = &cobra.Command{
}
func init() {
+ Cmd.Flags().StringVarP(&downloadAudioFlag, "audio",
+ "a",
+ "",
+ "The locale of the audio. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", "))
Cmd.Flags().StringVarP(&downloadSubtitleFlag,
"subtitle",
"s",
@@ -272,13 +280,69 @@ func downloadInfo(info utils.FormatInformation, file *os.File) error {
}
func downloadExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
- episodes, err := utils.ExtractEpisodes(url)
- if err != nil {
- return nil, err
+ var episodes [][]*crunchyroll.Episode
+ var final []*crunchyroll.Episode
+
+ if downloadAudioFlag != "" {
+ if _, ok := crunchyroll.ParseBetaEpisodeURL(url); ok {
+ return nil, fmt.Errorf("downloading episodes by url and specifying a language is no longer supported (thx crunchyroll). use the series url instead and filter after the given episode (https://github.com/crunchy-labs/crunchy-cli/wiki/Cli#filter)")
+ } else if _, _, _, _, ok := crunchyroll.ParseEpisodeURL(url); ok {
+ return nil, fmt.Errorf("downloading episodes by url and specifying a language is no longer supported (thx crunchyroll). use the series url instead and filter after the given episode (https://github.com/crunchy-labs/crunchy-cli/wiki/Cli#filter)")
+ }
+
+ var err error
+ episodes, err = utils.ExtractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag))
+ if err != nil {
+ return nil, err
+ }
+ japanese := episodes[0]
+ custom := episodes[1]
+
+ sort.Sort(crunchyUtils.EpisodesByNumber(japanese))
+ sort.Sort(crunchyUtils.EpisodesByNumber(custom))
+
+ var errMessages []string
+
+ if len(japanese) == 0 || len(japanese) == len(custom) {
+ final = custom
+ } else {
+ for _, jp := range japanese {
+ before := len(final)
+ for _, episode := range custom {
+ if jp.SeasonNumber == episode.SeasonNumber && jp.EpisodeNumber == episode.EpisodeNumber {
+ final = append(final, episode)
+ }
+ }
+ if before == len(final) {
+ errMessages = append(errMessages, fmt.Sprintf("%s has no %s audio, using %s as fallback", jp.Title, crunchyroll.LOCALE(downloadAudioFlag), crunchyroll.JP))
+ final = append(final, jp)
+ }
+ }
+ }
+
+ if len(errMessages) > 10 {
+ for _, msg := range errMessages[:10] {
+ utils.Log.SetProcess(msg)
+ }
+ utils.Log.SetProcess("... and %d more", len(errMessages)-10)
+ } else {
+ for _, msg := range errMessages {
+ utils.Log.SetProcess(msg)
+ }
+ }
+ } else {
+ var err error
+ episodes, err = utils.ExtractEpisodes(url)
+ if err != nil {
+ return nil, err
+ } else if len(episodes) == 0 {
+ return nil, fmt.Errorf("cannot find any episode")
+ }
+ final = episodes[0]
}
var infoFormat [][]utils.FormatInformation
- for _, season := range crunchyUtils.SortEpisodesBySeason(episodes[0]) {
+ for _, season := range crunchyUtils.SortEpisodesBySeason(final) {
tmpFormatInformation := make([]utils.FormatInformation, 0)
for _, episode := range season {
format, err := episode.GetFormat(downloadResolutionFlag, crunchyroll.LOCALE(downloadSubtitleFlag), true)
From ac876f674a77a030c82db589452977336721ed21 Mon Sep 17 00:00:00 2001
From: bytedream
Date: Sun, 14 Aug 2022 13:38:42 +0200
Subject: [PATCH 87/87] Update to newer crunchyroll-go version
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index d7f0eba..cb59580 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/crunchy-labs/crunchy-cli
go 1.18
require (
- github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a
+ github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220812161741-903599bcbe60
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.5.0
)
diff --git a/go.sum b/go.sum
index 225fce4..d134d52 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a h1:q5M2xmCTu2njig5rlRAd83LJDaPANmweFJjYsnxi5zM=
-github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220808230013-bb434a0fba7a/go.mod h1:SjTQD3IX7Z+MLsMSd2fP5ttsJ4KtpXY6r08bHLwrOLM=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220812161741-903599bcbe60 h1:cvEKs8D8816yWJDXYl8V7bYLYsAcbNbGGcUZDUofwTI=
+github.com/crunchy-labs/crunchyroll-go/v3 v3.0.0-20220812161741-903599bcbe60/go.mod h1:SjTQD3IX7Z+MLsMSd2fP5ttsJ4KtpXY6r08bHLwrOLM=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=