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

View file

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

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
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).

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"
"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)

View file

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

View file

@ -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())
c, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client)
if err != nil {
return err
}
if loginPersistentFlag {
if configDir, err := os.UserConfigDir(); err != nil {
return fmt.Errorf("could not save credentials persistent: %w", err)
} else {
var credentials []byte
if loginEncryptFlag {
var passwd []byte
for {
fmt.Print("Enter password: ")
passwd, err = readLineSilent()
if err != nil {
return err
}
fmt.Println()
fmt.Print("Enter password again: ")
repasswd, err := readLineSilent()
if err != nil {
return err
}
fmt.Println()
if !bytes.Equal(passwd, repasswd) {
fmt.Println("Passwords does not match, try again")
continue
}
hashedPassword := sha256.Sum256(passwd)
block, err := aes.NewCipher(hashedPassword[:])
if err != nil {
out.Err("Failed to create block: %w", err)
os.Exit(1)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
out.Err("Failed to create gcm: %w", err)
os.Exit(1)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
out.Err("Failed to fill nonce: %w", err)
os.Exit(1)
}
return os.WriteFile(loginStorePath(), []byte(fmt.Sprintf("%s\n%s", user, password)), 0600)
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
}

View file

@ -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",

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
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 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")
os.Exit(1)
}
out.Debug("Wrote session id to temp file")
}
}
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 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])
}
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)
}
split := strings.SplitN(string(body), "\n", 2)
if len(split) == 1 || split[1] == "" {
if strings.HasPrefix(split[0], "aes:") {
encrypted := body[4:]
out.StopProgress("Credentials are encrypted")
fmt.Print("Enter password to encrypt it: ")
passwd, err := readLineSilent()
fmt.Println()
if err != nil {
out.Err("Failed to read password; %w", err)
os.Exit(1)
}
out.SetProgress("Logging in")
hashedPassword := sha256.Sum256(passwd)
block, err := aes.NewCipher(hashedPassword[:])
if err != nil {
out.Err("Failed to create block: %w", err)
os.Exit(1)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
out.Err("Failed to create gcm: %w", err)
os.Exit(1)
}
nonce, c := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():]
b, err := gcm.Open(nil, nonce, c, nil)
if err != nil {
out.StopProgress("Invalid password")
os.Exit(1)
}
split = strings.SplitN(string(b), "\n", 2)
}
}
if len(split) == 2 {
if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
}
out.Debug("Logged in with credentials")
} else {
if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil {
out.StopProgress(err.Error())
os.Exit(1)
}
out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0])
}
// the etp rt is written to a temp file to reduce the amount of re-logging in.
// it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time
os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600)
out.StopProgress("Logged in")
return
}
}
out.StopProgress("To use this command, login first. Type `%s login -h` to get help", os.Args[0])
os.Exit(1)
}

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
import (
"github.com/ByteDream/crunchyroll-go/v2/cmd/crunchyroll-go/cmd"
"github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd"
)
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
.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.

View file

@ -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)
@ -106,28 +144,29 @@ func LoginWithCredentials(user string, password string, locale LOCALE, client *h
}
}
return LoginWithSessionID(sessionID, locale, client)
var etpRt string
for _, cookie := range resp.Cookies() {
if cookie.Name == "etp_rt" {
etpRt = cookie.Value
break
}
}
return postLogin(loginResp, etpRt, locale, client)
}
// LoginWithSessionID logs in via a crunchyroll session id.
// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com.
//
// Deprecated: Login via session id caused some trouble in the past (e.g. #15 or #30) which resulted in
// login not working. Use LoginWithEtpRt instead. EtpRt practically the crunchyroll beta equivalent to
// a session id.
// The method will stay in the library until session id login is removed completely or login with it
// does not work for a longer period of time.
func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
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
}

View file

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

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

View file

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

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 (
"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
}

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 (
"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
}

View file

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

View file

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

View file

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

View file

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