Merge branch 'next/v3' into v3/feature/non-premium-support

This commit is contained in:
ByteDream 2022-06-10 16:04:25 +02:00 committed by GitHub
commit ae075ed4c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1173 additions and 205 deletions

View file

@ -11,7 +11,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: 1.16 go-version: 1.18
- name: Build - name: Build
run: go build -v cmd/crunchyroll-go/main.go run: go build -v cmd/crunchyroll-go/main.go

View file

@ -1,4 +1,4 @@
VERSION=2.2.1 VERSION=development
BINARY_NAME=crunchy BINARY_NAME=crunchy
VERSION_BINARY_NAME=$(BINARY_NAME)-v$(VERSION) VERSION_BINARY_NAME=$(BINARY_NAME)-v$(VERSION)
@ -6,7 +6,7 @@ DESTDIR=
PREFIX=/usr PREFIX=/usr
build: 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: clean:
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_* rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
@ -24,8 +24,8 @@ uninstall:
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
release: 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=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/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe 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/v2/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin 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 strip $(VERSION_BINARY_NAME)_linux

View file

@ -1,5 +1,3 @@
<p align="center"><strong>Version 2 is out 🥳, see all the <a href="https://github.com/ByteDream/crunchyroll-go/releases/tag/v2.0.0">changes</a></strong>.</p>
# crunchyroll-go # 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. 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` Download the library via `go get`
```shell ```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). Examples how to use the library and some features of it are described in the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Library).

29
account.go Normal file
View file

@ -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"`
}

43
category.go Normal file
View file

@ -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"`
}

View file

@ -8,11 +8,12 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"fmt" "fmt"
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"github.com/ByteDream/crunchyroll-go/v2/utils" "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"io" "io"
"math"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@ -98,9 +99,12 @@ var archiveCmd = &cobra.Command{
} }
switch archiveResolutionFlag { switch archiveResolutionFlag {
case "1080p", "720p", "480p", "360p", "240p": case "1080p", "720p", "480p", "360p":
intRes, _ := strconv.ParseFloat(strings.TrimSuffix(archiveResolutionFlag, "p"), 84) intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84)
archiveResolutionFlag = fmt.Sprintf("%dx%s", int(intRes*(16/9)), strings.TrimSuffix(archiveResolutionFlag, "p")) 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": case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst":
default: default:
return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag) return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag)

View file

@ -3,10 +3,11 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"github.com/ByteDream/crunchyroll-go/v2/utils" "github.com/ByteDream/crunchyroll-go/v3/utils"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"math"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@ -53,9 +54,12 @@ var downloadCmd = &cobra.Command{
out.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag) out.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
switch downloadResolutionFlag { switch downloadResolutionFlag {
case "1080p", "720p", "480p", "360p", "240p": case "1080p", "720p", "480p", "360p":
intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84) 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": case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst":
default: default:
return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag) return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag)

View file

@ -1,19 +1,25 @@
package cmd package cmd
import ( import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt" "fmt"
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"io"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"runtime"
) )
var ( var (
loginPersistentFlag bool loginPersistentFlag bool
loginEncryptFlag bool
loginSessionIDFlag bool loginSessionIDFlag bool
loginEtpRtFlag bool
) )
var loginCmd = &cobra.Command{ var loginCmd = &cobra.Command{
@ -21,11 +27,13 @@ var loginCmd = &cobra.Command{
Short: "Login to crunchyroll", Short: "Login to crunchyroll",
Args: cobra.RangeArgs(1, 2), Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) { RunE: func(cmd *cobra.Command, args []string) error {
if loginSessionIDFlag { if loginSessionIDFlag {
loginSessionID(args[0]) return loginSessionID(args[0])
} else if loginEtpRtFlag {
return loginEtpRt(args[0])
} else { } else {
loginCredentials(args[0], args[1]) return loginCredentials(args[0], args[1])
} }
}, },
} }
@ -35,47 +43,164 @@ func init() {
"persistent", "persistent",
false, false,
"If the given credential should be stored persistent") "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, loginCmd.Flags().BoolVar(&loginSessionIDFlag,
"session-id", "session-id",
false, false,
"Use a session id to login instead of username and password") "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) rootCmd.AddCommand(loginCmd)
} }
func loginCredentials(user, password string) error { func loginCredentials(user, password string) error {
out.Debug("Logging in via credentials") out.Debug("Logging in via credentials")
if _, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client); err != nil { c, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client)
out.Err(err.Error()) if err != nil {
os.Exit(1) 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 { func loginSessionID(sessionID string) error {
out.Debug("Logging in via session id") 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()) out.Err(err.Error())
os.Exit(1) os.Exit(1)
} }
return os.WriteFile(loginStorePath(), []byte(sessionID), 0600)
}
func loginStorePath() string {
path := filepath.Join(os.TempDir(), ".crunchy")
if loginPersistentFlag { if loginPersistentFlag {
if runtime.GOOS != "windows" { if configDir, err := os.UserConfigDir(); err != nil {
usr, _ := user.Current() return fmt.Errorf("could not save credentials persistent: %w", err)
path = filepath.Join(usr.HomeDir, ".config/crunchy") } 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) if !loginPersistentFlag {
} else if runtime.GOOS != "windows" {
out.Info("Due to security reasons, you have to login again on the next reboot") 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
} }

View file

@ -3,7 +3,7 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"net/http" "net/http"
"os" "os"
@ -27,7 +27,7 @@ var (
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "crunchyroll", Use: "crunchyroll-go",
Version: Version, 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/crunchyroll-go/wiki",

View file

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

View file

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

View file

@ -1,14 +1,16 @@
package cmd package cmd
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt" "fmt"
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"github.com/ByteDream/crunchyroll-go/v2/utils" "github.com/ByteDream/crunchyroll-go/v3/utils"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"os/user"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
@ -141,57 +143,92 @@ func freeFileName(filename string) (string, bool) {
func loadCrunchy() { func loadCrunchy() {
out.SetProgress("Logging in") out.SetProgress("Logging in")
files := []string{filepath.Join(os.TempDir(), ".crunchy")} tmpFilePath := filepath.Join(os.TempDir(), ".crunchy")
if _, statErr := os.Stat(tmpFilePath); !os.IsNotExist(statErr) {
if runtime.GOOS != "windows" { body, err := os.ReadFile(tmpFilePath)
usr, _ := user.Current() if err != nil {
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 {
out.StopProgress("Failed to read login information: %v", err) out.StopProgress("Failed to read login information: %v", err)
os.Exit(1) os.Exit(1)
} else if body == nil {
continue
} }
if crunchy, err = crunchyroll.LoginWithEtpRt(string(body), systemLocale(true), client); err != nil {
split := strings.SplitN(string(body), "\n", 2) out.Debug("Failed to login with temp etp rt cookie: %v", err)
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])
}
} else { } else {
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil { out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body)
continue
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]) split := strings.SplitN(string(body), "\n", 2)
if file != filepath.Join(os.TempDir(), ".crunchy") { if len(split) == 1 || split[1] == "" {
// the session id is written to a temp file to reduce the amount of re-logging in. if strings.HasPrefix(split[0], "aes:") {
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time encrypted := body[4:]
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") 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) 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) os.Exit(1)
} }

View file

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

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd" "github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd"
) )
func main() { func main() {

View file

@ -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 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 .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 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 .SH DESCRIPTION
.TP .TP
@ -141,6 +143,13 @@ The video resolution. Can either be specified via the pixels (e.g. 1920x1080), t
\fB-g, --goroutines GOROUTINES\fR \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. 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 .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. 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. 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.

View file

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -31,6 +30,23 @@ const (
AR = "ar-SA" 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 { type Crunchyroll struct {
// Client is the http.Client to perform all requests over. // Client is the http.Client to perform all requests over.
Client *http.Client Client *http.Client
@ -38,8 +54,9 @@ type Crunchyroll struct {
Context context.Context Context context.Context
// Locale specifies in which language all results should be returned / requested. // Locale specifies in which language all results should be returned / requested.
Locale LOCALE Locale LOCALE
// SessionID is the crunchyroll session id which was used for authentication. // EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
SessionID string // this struct in v2 and below).
EtpRt string
// Config stores parameters which are needed by some api calls. // Config stores parameters which are needed by some api calls.
Config struct { Config struct {
@ -62,38 +79,59 @@ type Crunchyroll struct {
cache bool 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. // LoginWithCredentials logs in via crunchyroll username or email and password.
func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) { 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", endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
"LNDJgOit5yaRIWN", "com.crunchyroll.windows.desktop", "Az2srGnChW65fuxYz2Xxl1GcZQgtGgI") values := url.Values{}
sessResp, err := client.Get(sessionIDEndpoint) 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 { if err != nil {
return nil, err 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 { resp, err := request(req, client)
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 { if err != nil {
return nil, err return nil, err
} }
defer loginResp.Body.Close() defer resp.Body.Close()
if loginResp.StatusCode != http.StatusOK { if loginResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to auth with credentials: %s", loginResp.Status) 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) { if loginRespBody["error"].(bool) {
return nil, fmt.Errorf("an unexpected login error occoured: %s", loginRespBody["message"]) 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. // LoginWithSessionID logs in via a crunchyroll session id.
// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com. // 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) { func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
crunchy := &Crunchyroll{ endpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
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",
sessionID) sessionID)
resp, err = client.Get(endpoint) resp, err := client.Get(endpoint)
if err != nil { if err != nil {
return nil, err 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) 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 { 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) 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{}) data := jsonBody["data"].(map[string]interface{})
@ -160,48 +201,80 @@ func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*
} }
} }
// token return LoginWithEtpRt(etpRt, locale, client)
endpoint = "https://beta-api.crunchyroll.com/auth/v1/token" }
// 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 := url.Values{}
grantType.Set("grant_type", "etp_rt_cookie") 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 { if err != nil {
return nil, err return nil, err
} }
authRequest.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6") req.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
authRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
authRequest.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: "session_id",
Value: sessionID,
})
authRequest.AddCookie(&http.Cookie{
Name: "etp_rt", Name: "etp_rt",
Value: etpRt, 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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { json.NewDecoder(resp.Body).Decode(&jsonBody)
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)
// index cms := jsonBody["cms"].(map[string]any)
endpoint = "https://beta-api.crunchyroll.com/index/v2" crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
resp, err = crunchy.request(endpoint) if strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll") {
if err != nil { crunchy.Config.Premium = true
return nil, err 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 { if strings.Contains(cms["bucket"].(string), "crunchyroll") {
return nil, fmt.Errorf("failed to parse 'index' response: %w", err) 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. // / is trimmed so that urls which require it must be in .../{bucket}/... like format.
// this just looks cleaner // 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.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string) crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
// me
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { json.NewDecoder(resp.Body).Decode(&jsonBody)
return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
}
crunchy.Config.AccountID = jsonBody["account_id"].(string)
crunchy.Config.ExternalID = jsonBody["external_id"].(string) crunchy.Config.ExternalID = jsonBody["external_id"].(string)
//profile
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil { json.NewDecoder(resp.Body).Decode(&jsonBody)
return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
}
crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string) crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string)
return crunchy, nil 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. // request is a base function which handles api requests.
func (c *Crunchyroll) request(endpoint string) (*http.Response, error) { func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil) req, err := http.NewRequest(method, endpoint, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken)) req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
resp, err := c.Client.Do(req) return request(req, c.Client)
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
} }
// IsCaching returns if data gets cached or not. // 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) { 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", searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s",
query, limit, c.Locale) query, limit, c.Locale)
resp, err := c.request(searchEndpoint) resp, err := c.request(searchEndpoint, http.MethodGet)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -352,7 +436,7 @@ func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) {
} else if len(m) > 0 { } else if len(m) > 0 {
return m[0], nil 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. // 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 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
}

View file

@ -3,6 +3,7 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -75,6 +76,17 @@ type Episode struct {
StreamID string 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. // EpisodeFromID returns an episode by its api id.
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) { 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", 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.Locale,
crunchy.Config.Signature, crunchy.Config.Signature,
crunchy.Config.Policy, crunchy.Config.Policy,
crunchy.Config.KeyPairID)) crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }

2
go.mod
View file

@ -1,4 +1,4 @@
module github.com/ByteDream/crunchyroll-go/v2 module github.com/ByteDream/crunchyroll-go/v3
go 1.18 go 1.18

View file

@ -3,6 +3,7 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
) )
// MovieListing contains information about something which is called // MovieListing contains information about something which is called
@ -46,7 +47,7 @@ func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error)
crunchy.Locale, crunchy.Locale,
crunchy.Config.Signature, crunchy.Config.Signature,
crunchy.Config.Policy, crunchy.Config.Policy,
crunchy.Config.KeyPairID)) crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -73,7 +74,7 @@ func (ml *MovieListing) AudioLocale() (LOCALE, error) {
ml.crunchy.Locale, ml.crunchy.Locale,
ml.crunchy.Config.Signature, ml.crunchy.Config.Signature,
ml.crunchy.Config.Policy, ml.crunchy.Config.Policy,
ml.crunchy.Config.KeyPairID)) ml.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return "", err return "", err
} }

11
news.go Normal file
View file

@ -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"`
}

View file

@ -3,6 +3,7 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"regexp" "regexp"
) )
@ -101,7 +102,7 @@ func (s *Season) Episodes() (episodes []*Episode, err error) {
s.crunchy.Locale, s.crunchy.Locale,
s.crunchy.Config.Signature, s.crunchy.Config.Signature,
s.crunchy.Config.Policy, s.crunchy.Config.Policy,
s.crunchy.Config.KeyPairID)) s.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }

13
simulcast.go Normal file
View file

@ -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"`
}

View file

@ -2,9 +2,9 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
"net/http"
"regexp" "regexp"
) )
@ -71,7 +71,7 @@ func (s *Stream) Formats() ([]*Format, error) {
// fromVideoStreams returns all streams which are accessible via the endpoint. // fromVideoStreams returns all streams which are accessible via the endpoint.
func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream, err error) { 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -2,6 +2,10 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/url"
"reflect"
"strings"
) )
func decodeMapToStruct(m interface{}, s interface{}) error { func decodeMapToStruct(m interface{}, s interface{}) error {
@ -23,3 +27,46 @@ func regexGroups(parsed [][]string, subexpNames ...string) map[string]string {
} }
return groups 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
}

View file

@ -1,7 +1,7 @@
package utils package utils
import ( import (
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
) )
// AllLocales is an array of all available locales. // AllLocales is an array of all available locales.

View file

@ -1,7 +1,7 @@
package utils package utils
import ( import (
"github.com/ByteDream/crunchyroll-go/v2" "github.com/ByteDream/crunchyroll-go/v3"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"

View file

@ -3,6 +3,7 @@ package crunchyroll
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
) )
type video struct { type video struct {
@ -75,7 +76,7 @@ func MovieFromID(crunchy *Crunchyroll, id string) (*Movie, error) {
crunchy.Locale, crunchy.Locale,
crunchy.Config.Signature, crunchy.Config.Signature,
crunchy.Config.Policy, crunchy.Config.Policy,
crunchy.Config.KeyPairID)) crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -106,7 +107,7 @@ func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
m.crunchy.Locale, m.crunchy.Locale,
m.crunchy.Config.Signature, m.crunchy.Config.Signature,
m.crunchy.Config.Policy, m.crunchy.Config.Policy,
m.crunchy.Config.KeyPairID)) m.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -167,7 +168,7 @@ func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
crunchy.Locale, crunchy.Locale,
crunchy.Config.Signature, crunchy.Config.Signature,
crunchy.Config.Policy, crunchy.Config.Policy,
crunchy.Config.KeyPairID)) crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -198,7 +199,7 @@ func (s *Series) Seasons() (seasons []*Season, err error) {
s.crunchy.Locale, s.crunchy.Locale,
s.crunchy.Config.Signature, s.crunchy.Config.Signature,
s.crunchy.Config.Policy, s.crunchy.Config.Policy,
s.crunchy.Config.KeyPairID)) s.crunchy.Config.KeyPairID), http.MethodGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }