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 diff --git a/Makefile b/Makefile index d23fd11..a747c1d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION=2.2.1 +VERSION=development BINARY_NAME=crunchy VERSION_BINARY_NAME=$(BINARY_NAME)-v$(VERSION) @@ -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..46862f0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -

Version 2 is out 🥳, see all the changes.

- # crunchyroll-go 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. @@ -205,10 +203,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/account.go b/account.go new file mode 100644 index 0000000..d22eba5 --- /dev/null +++ b/account.go @@ -0,0 +1,29 @@ +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 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"` +} 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/cmd/crunchyroll-go/cmd/archive.go b/cmd/crunchyroll-go/cmd/archive.go index 63e5d5a..0d47917 100644 --- a/cmd/crunchyroll-go/cmd/archive.go +++ b/cmd/crunchyroll-go/cmd/archive.go @@ -8,11 +8,12 @@ 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" + "math" "os" "os/exec" "os/signal" @@ -98,9 +99,12 @@ var archiveCmd = &cobra.Command{ } switch archiveResolutionFlag { - case "1080p", "720p", "480p", "360p", "240p": - intRes, _ := strconv.ParseFloat(strings.TrimSuffix(archiveResolutionFlag, "p"), 84) - archiveResolutionFlag = fmt.Sprintf("%dx%s", int(intRes*(16/9)), strings.TrimSuffix(archiveResolutionFlag, "p")) + 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")) + case "240p": + // 240p would round up to 427x240 if used in the case statement above, so it has to be handled separately + archiveResolutionFlag = "428x240" case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst": default: return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag) diff --git a/cmd/crunchyroll-go/cmd/download.go b/cmd/crunchyroll-go/cmd/download.go index 1254048..74cc3f2 100644 --- a/cmd/crunchyroll-go/cmd/download.go +++ b/cmd/crunchyroll-go/cmd/download.go @@ -3,10 +3,11 @@ 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" "os" "os/signal" "path/filepath" @@ -53,9 +54,12 @@ var downloadCmd = &cobra.Command{ out.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag) switch downloadResolutionFlag { - case "1080p", "720p", "480p", "360p", "240p": + case "1080p", "720p", "480p", "360p": intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84) - downloadResolutionFlag = fmt.Sprintf("%dx%s", int(intRes*(16/9)), strings.TrimSuffix(downloadResolutionFlag, "p")) + downloadResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(downloadResolutionFlag, "p")) + case "240p": + // 240p would round up to 427x240 if used in the case statement above, so it has to be handled separately + downloadResolutionFlag = "428x240" case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst": default: return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag) diff --git a/cmd/crunchyroll-go/cmd/login.go b/cmd/crunchyroll-go/cmd/login.go index 5bef2a9..d8cc5c3 100644 --- a/cmd/crunchyroll-go/cmd/login.go +++ b/cmd/crunchyroll-go/cmd/login.go @@ -1,19 +1,25 @@ package cmd import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" "fmt" - "github.com/ByteDream/crunchyroll-go/v2" + "github.com/ByteDream/crunchyroll-go/v3" "github.com/spf13/cobra" + "io" "os" - "os/user" "path/filepath" - "runtime" ) var ( loginPersistentFlag bool + loginEncryptFlag bool loginSessionIDFlag bool + loginEtpRtFlag bool ) var loginCmd = &cobra.Command{ @@ -21,11 +27,13 @@ var loginCmd = &cobra.Command{ Short: "Login to crunchyroll", Args: cobra.RangeArgs(1, 2), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if loginSessionIDFlag { - loginSessionID(args[0]) + return loginSessionID(args[0]) + } else if loginEtpRtFlag { + return loginEtpRt(args[0]) } else { - loginCredentials(args[0], args[1]) + return loginCredentials(args[0], args[1]) } }, } @@ -35,47 +43,164 @@ 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 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") - if _, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client); err != nil { - out.Err(err.Error()) - os.Exit(1) + c, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client) + if err != nil { + return err } - return os.WriteFile(loginStorePath(), []byte(fmt.Sprintf("%s\n%s", user, password)), 0600) + 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"), 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")) + } + } + } + + 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") - 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) } - return os.WriteFile(loginStorePath(), []byte(sessionID), 0600) -} - -func loginStorePath() string { - path := filepath.Join(os.TempDir(), ".crunchy") if loginPersistentFlag { - if runtime.GOOS != "windows" { - usr, _ := user.Current() - path = filepath.Join(usr.HomeDir, ".config/crunchy") + 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 + } - out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", path) - } else if runtime.GOOS != "windows" { + if !loginPersistentFlag { out.Info("Due to security reasons, you have to login again on the next reboot") } - return path + 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, "crunchyroll-go"), 0755) + 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(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/cmd/crunchyroll-go/cmd/root.go b/cmd/crunchyroll-go/cmd/root.go index 14f0c0e..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" @@ -27,7 +27,7 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "crunchyroll", + Use: "crunchyroll-go", 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", 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/update.go b/cmd/crunchyroll-go/cmd/update.go new file mode 100644 index 0000000..c8512e6 --- /dev/null +++ b/cmd/crunchyroll-go/cmd/update.go @@ -0,0 +1,136 @@ +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) + } + + 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 is newer than version in latest release (%s)", 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): https://github.com/ByteDream/crunchyroll-go/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) + } + + 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/cmd/crunchyroll-go/cmd/utils.go b/cmd/crunchyroll-go/cmd/utils.go index 29a48f7..12d3d4e 100644 --- a/cmd/crunchyroll-go/cmd/utils.go +++ b/cmd/crunchyroll-go/cmd/utils.go @@ -1,14 +1,16 @@ package cmd import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" "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" "os/exec" - "os/user" "path/filepath" "reflect" "regexp" @@ -141,57 +143,92 @@ func freeFileName(filename string) (string, bool) { func loadCrunchy() { out.SetProgress("Logging in") - files := []string{filepath.Join(os.TempDir(), ".crunchy")} - - if runtime.GOOS != "windows" { - usr, _ := user.Current() - files = append(files, filepath.Join(usr.HomeDir, ".config/crunchy")) - } - - var err error - for _, file := range files { - if _, err = os.Stat(file); os.IsNotExist(err) { - err = nil - continue - } - var body []byte - if body, err = os.ReadFile(file); err != nil { + 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) - } else if body == nil { - continue } - - split := strings.SplitN(string(body), "\n", 2) - if len(split) == 1 || split[1] == "" { - if crunchy, err = crunchyroll.LoginWithSessionID(split[0], systemLocale(true), client); err == nil { - 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 crunchy, err = crunchyroll.LoginWithEtpRt(string(body), systemLocale(true), client); err != nil { + out.Debug("Failed to login with temp etp rt cookie: %v", err) } else { - if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil { - continue + 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, "crunchyroll-go", "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) } - out.Debug("Logged in with username '%s' and password '%s'. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0], split[1]) - if file != filepath.Join(os.TempDir(), ".crunchy") { - // 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 - if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.SessionID), 0600); err != nil { - out.StopProgress("Failed to write session id to temp file") + 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("Wrote session id to temp file") + 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("Logged in") - return - } - if err != nil { - out.StopProgress(err.Error()) - } else { - out.StopProgress("To use this command, login first. Type `%s login -h` to get help", os.Args[0]) } + out.StopProgress("To use this command, login first. Type `%s login -h` to get help", os.Args[0]) os.Exit(1) } 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 +} 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/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. diff --git a/crunchyroll.go b/crunchyroll.go index d3b1ce8..73cba57 100644 --- a/crunchyroll.go +++ b/crunchyroll.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -31,6 +30,23 @@ const ( AR = "ar-SA" ) +// MediaType represents a media type. +type MediaType string + +const ( + SERIES MediaType = "series" + 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 @@ -38,8 +54,9 @@ 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 { @@ -62,38 +79,59 @@ 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 SortType `param:"sort_by"` + + // Start specifies the index from which the entries should be returned. + Start uint `param:"start"` + + // Type specifies the media type of the entries. + Type MediaType `param:"type"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + 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) { - 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) + 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 } - defer sessResp.Body.Close() + req.Header.Set("Authorization", "Basic aHJobzlxM2F3dnNrMjJ1LXRzNWE6cHROOURteXRBU2Z6QjZvbXVsSzh6cUxzYTczVE1TY1k=") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - 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())) + resp, err := request(req, client) if err != nil { return nil, err } - defer loginResp.Body.Close() + defer resp.Body.Close() if loginResp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to auth with credentials: %s", loginResp.Status) @@ -103,31 +141,32 @@ func LoginWithCredentials(user string, password string, locale LOCALE, client *h if loginRespBody["error"].(bool) { return nil, fmt.Errorf("an unexpected login error occoured: %s", loginRespBody["message"]) + } + } + + var etpRt string + for _, cookie := range resp.Cookies() { + if cookie.Name == "etp_rt" { + etpRt = cookie.Value + break } } - return LoginWithSessionID(sessionID, locale, client) + 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) { - 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{} - - // start session - endpoint = fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s", + endpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s", sessionID) - resp, err = client.Get(endpoint) + resp, err := client.Get(endpoint) if err != nil { return nil, err } @@ -137,11 +176,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 _, ok := jsonBody["message"]; ok { - return nil, errors.New("invalid session id") + + 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{}) @@ -160,48 +201,80 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (* } } - // token - endpoint = "https://beta-api.crunchyroll.com/auth/v1/token" + 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 + 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() - if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { - return nil, fmt.Errorf("failed to parse 'token' response: %w", err) - } - crunchy.Config.TokenType = jsonBody["token_type"].(string) - crunchy.Config.AccessToken = jsonBody["access_token"].(string) + json.NewDecoder(resp.Body).Decode(&jsonBody) - // index - endpoint = "https://beta-api.crunchyroll.com/index/v2" - resp, err = crunchy.request(endpoint) - if err != nil { - return nil, err + 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 = "-" } - 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) + + if strings.Contains(cms["bucket"].(string), "crunchyroll") { + crunchy.Config.Premium = true + crunchy.Config.Channel = "crunchyroll" + } else { + crunchy.Config.Premium = false + crunchy.Config.Channel = "-" } - cms := jsonBody["cms"].(map[string]interface{}) // / is trimmed so that urls which require it must be in .../{bucket}/... like format. // this just looks cleaner @@ -210,63 +283,74 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (* 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) + resp, err = crunchy.request(endpoint, http.MethodGet) 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) + resp, err = crunchy.request(endpoint, http.MethodGet) 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) +func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) { + req, err := http.NewRequest(method, endpoint, nil) if err != nil { return nil, err } req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) - 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. @@ -287,7 +371,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 } @@ -352,7 +436,7 @@ func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) { } else if len(m) > 0 { return m[0], nil } - return nil, errors.New("no series or movie could be found") + return nil, fmt.Errorf("no series or movie could be found") } // FindEpisodeByName finds an episode by its crunchyroll series name and episode title. @@ -449,3 +533,327 @@ 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, http.MethodGet) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'browse' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// Categories returns all available categories and possible subcategories. +func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err error) { + tenantCategoriesEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/tenant_categories?include_subcategories=%t&locale=%s", + includeSubcategories, c.Locale) + resp, err := c.request(tenantCategoriesEndpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'tenant_categories' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + category := &Category{} + if err := decodeMapToStruct(item, category); err != nil { + return nil, err + } + + ca = append(ca, category) + } + + return ca, nil +} + +// Simulcasts returns all available simulcast seasons for the current locale. +func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) { + seasonListEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/season_list?locale=%s", c.Locale) + resp, err := c.request(seasonListEndpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'season_list' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + simulcast := &Simulcast{} + if err := decodeMapToStruct(item, simulcast); err != nil { + return nil, err + } + + s = append(s, simulcast) + } + + return s, nil +} + +// News returns the top and latest news from crunchyroll for the current locale within the given limits. +func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*News, err error) { + newsFeedEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/news_feed?top_news_n=%d&latest_news_n=%d&locale=%s", + topLimit, latestLimit, c.Locale) + resp, err := c.request(newsFeedEndpoint, http.MethodGet) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'news_feed' response: %w", err) + } + + topNews := jsonBody["top_news"].(map[string]interface{}) + for _, item := range topNews["items"].([]interface{}) { + topNews := &News{} + if err := decodeMapToStruct(item, topNews); err != nil { + return nil, nil, err + } + + t = append(t, topNews) + } + + latestNews := jsonBody["latest_news"].(map[string]interface{}) + for _, item := range latestNews["items"].([]interface{}) { + latestNews := &News{} + if err := decodeMapToStruct(item, latestNews); err != nil { + return nil, nil, err + } + + l = append(l, latestNews) + } + + return t, l, nil +} + +// Recommendations returns series and movie recommendations from crunchyroll based on the currently logged in account within the given limit. +func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err error) { + recommendationsEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/recommendations?n=%d&locale=%s", + c.Config.AccountID, limit, c.Locale) + resp, err := c.request(recommendationsEndpoint, http.MethodGet) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'recommendations' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// UpNext returns the episodes that are up next based on the currently logged in account within the given limit. +func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) { + upNextAccountEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/up_next_account?n=%d&locale=%s", + c.Config.AccountID, limit, c.Locale) + resp, err := c.request(upNextAccountEndpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'up_next_account' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + panel := item.(map[string]interface{})["panel"] + + episode := &Episode{ + crunchy: c, + } + if err := decodeMapToStruct(panel, episode); err != nil { + return nil, err + } + + e = append(e, episode) + } + + return e, nil +} + +// SimilarTo returns similar series and movies according to crunchyroll to the one specified by id within the given limit. +func (c *Crunchyroll) SimilarTo(id string, limit uint) (s []*Series, m []*Movie, err error) { + similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s", + c.Config.AccountID, id, limit, c.Locale) + resp, err := c.request(similarToEndpoint, http.MethodGet) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse 'similar_to' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + switch item.(map[string]interface{})["type"] { + case "series": + series := &Series{ + crunchy: c, + } + if err := decodeMapToStruct(item, series); err != nil { + return nil, nil, err + } + if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil { + return nil, nil, err + } + + s = append(s, series) + case "movie_listing": + movie := &Movie{ + crunchy: c, + } + if err := decodeMapToStruct(item, movie); err != nil { + return nil, nil, err + } + + m = append(m, movie) + } + } + + return s, m, nil +} + +// WatchHistory returns the history of watched episodes based on the currently logged in account from the given page with the given size. +func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, err error) { + watchHistoryEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/watch-history/%s?page=%d&page_size=%d&locale=%s", + c.Config.AccountID, page, size, c.Locale) + resp, err := c.request(watchHistoryEndpoint, http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jsonBody map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { + return nil, fmt.Errorf("failed to parse 'watch-history' response: %w", err) + } + + for _, item := range jsonBody["items"].([]interface{}) { + panel := item.(map[string]interface{})["panel"] + + episode := &Episode{ + crunchy: c, + } + if err := decodeMapToStruct(panel, episode); err != nil { + return nil, err + } + + historyEpisode := &HistoryEpisode{ + Episode: episode, + } + if err := decodeMapToStruct(item, historyEpisode); err != nil { + return nil, err + } + + e = append(e, historyEpisode) + } + + return e, nil +} + +// Account returns information about the currently logged in crunchyroll account. +func (c *Crunchyroll) Account() (*Account, error) { + resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + account := &Account{} + + if err = json.NewDecoder(resp.Body).Decode(&account); err != nil { + return nil, fmt.Errorf("failed to parse 'me' response: %w", err) + } + + resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&account); err != nil { + return nil, fmt.Errorf("failed to parse 'profile' response: %w", err) + } + + return account, nil +} diff --git a/episode.go b/episode.go index 3a2e403..c90a5c7 100644 --- a/episode.go +++ b/episode.go @@ -3,6 +3,7 @@ package crunchyroll import ( "encoding/json" "fmt" + "net/http" "regexp" "strconv" "strings" @@ -75,6 +76,17 @@ 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 + + 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"` +} + // 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", @@ -83,7 +95,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/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/movie_listing.go b/movie_listing.go index 80b7b46..ec49e99 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 @@ -46,7 +47,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 } @@ -73,7 +74,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/news.go b/news.go new file mode 100644 index 0000000..d90dd65 --- /dev/null +++ b/news.go @@ -0,0 +1,11 @@ +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"` +} diff --git a/season.go b/season.go index 0bb8623..907122d 100644 --- a/season.go +++ b/season.go @@ -3,6 +3,7 @@ package crunchyroll import ( "encoding/json" "fmt" + "net/http" "regexp" ) @@ -101,7 +102,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/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/stream.go b/stream.go index 0fa54b9..042d5f7 100644 --- a/stream.go +++ b/stream.go @@ -2,9 +2,9 @@ package crunchyroll import ( "encoding/json" - "errors" "fmt" "github.com/grafov/m3u8" + "net/http" "regexp" ) @@ -71,7 +71,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/utils.go b/utils.go index a3d4191..d32d39b 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,46 @@ 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. + 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 +} 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 e06946c..661eea8 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" diff --git a/video.go b/video.go index 8a7ee76..f72a327 100644 --- a/video.go +++ b/video.go @@ -3,6 +3,7 @@ package crunchyroll import ( "encoding/json" "fmt" + "net/http" ) type video struct { @@ -75,7 +76,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 } @@ -106,7 +107,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 } @@ -167,7 +168,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 } @@ -198,7 +199,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 }