mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 12:12:00 -06:00
Remove library & refactor cli
This commit is contained in:
parent
0fed0f8d3b
commit
8a3e42e4d1
45 changed files with 117 additions and 3687 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
go-version: 1.18
|
go-version: 1.18
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v cmd/crunchyroll-go/main.go
|
run: go build -v .
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -v cmd/crunchyroll-go/main.go
|
run: go test -v .
|
||||||
|
|
|
||||||
22
Makefile
22
Makefile
|
|
@ -6,26 +6,26 @@ DESTDIR=
|
||||||
PREFIX=/usr
|
PREFIX=/usr
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(BINARY_NAME) cmd/crunchyroll-go/main.go
|
go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(BINARY_NAME) .
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
|
rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_*
|
||||||
|
|
||||||
install:
|
install:
|
||||||
install -Dm755 $(BINARY_NAME) $(DESTDIR)$(PREFIX)/bin/crunchyroll-go
|
install -Dm755 $(BINARY_NAME) $(DESTDIR)$(PREFIX)/bin/crunchy-cli
|
||||||
ln -sf ./crunchyroll-go $(DESTDIR)$(PREFIX)/bin/crunchy
|
ln -sf ./crunchy-cli $(DESTDIR)$(PREFIX)/bin/crunchy
|
||||||
install -Dm644 crunchyroll-go.1 $(DESTDIR)$(PREFIX)/share/man/man1/crunchyroll-go.1
|
install -Dm644 crunchy-cli.1 $(DESTDIR)$(PREFIX)/share/man/man1/crunchy-cli.1
|
||||||
install -Dm644 LICENSE $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
|
install -Dm644 LICENSE $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(PREFIX)/bin/crunchyroll-go
|
rm -f $(DESTDIR)$(PREFIX)/bin/crunchy-cli
|
||||||
rm -f $(DESTDIR)$(PREFIX)/bin/crunchy
|
rm -f $(DESTDIR)$(PREFIX)/bin/crunchy
|
||||||
rm -f $(DESTDIR)$(PREFIX)/share/man/man1/crunchyroll-go.1
|
rm -f $(DESTDIR)$(PREFIX)/share/man/man1/crunchy-cli.1
|
||||||
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchyroll-go/LICENSE
|
rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux cmd/crunchyroll-go/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux .
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/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/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe .
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchyroll-go/v3/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/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin .
|
||||||
|
|
||||||
strip $(VERSION_BINARY_NAME)_linux
|
strip $(VERSION_BINARY_NAME)_linux
|
||||||
|
|
|
||||||
76
README.md
76
README.md
|
|
@ -1,41 +1,42 @@
|
||||||
# crunchyroll-go
|
# crunchy-cli
|
||||||
|
|
||||||
A [Go](https://golang.org) library & cli for the undocumented [crunchyroll](https://www.crunchyroll.com) api. To use it, you need a crunchyroll premium account to for full (api) access.
|
A [go](https://golang.org) written cli client for [crunchyroll](https://www.crunchyroll.com). To use it, you need a crunchyroll premium account to for full (api) access.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/ByteDream/crunchyroll-go">
|
<a href="https://github.com/ByteDream/crunchy-cli">
|
||||||
<img src="https://img.shields.io/github/languages/code-size/ByteDream/crunchyroll-go?style=flat-square" alt="Code size">
|
<img src="https://img.shields.io/github/languages/code-size/ByteDream/crunchy-cli?style=flat-square" alt="Code size">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/ByteDream/crunchyroll-go/releases/latest">
|
<a href="https://github.com/ByteDream/crunchy-cli/releases/latest">
|
||||||
<img src="https://img.shields.io/github/downloads/ByteDream/crunchyroll-go/total?style=flat-square" alt="Download Badge">
|
<img src="https://img.shields.io/github/downloads/ByteDream/crunchy-cli/total?style=flat-square" alt="Download Badge">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/ByteDream/crunchyroll-go/blob/master/LICENSE">
|
<a href="https://github.com/ByteDream/crunchy-cli/blob/master/LICENSE">
|
||||||
<img src="https://img.shields.io/github/license/ByteDream/crunchyroll-go?style=flat-square" alt="License">
|
<img src="https://img.shields.io/github/license/ByteDream/crunchy-cli?style=flat-square" alt="License">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://golang.org">
|
<a href="https://golang.org">
|
||||||
<img src="https://img.shields.io/github/go-mod/go-version/ByteDream/crunchyroll-go?style=flat-square" alt="Go version">
|
<img src="https://img.shields.io/github/go-mod/go-version/ByteDream/crunchy-cli?style=flat-square" alt="Go version">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/ByteDream/crunchyroll-go/releases/latest">
|
<a href="https://github.com/ByteDream/crunchy-cli/releases/latest">
|
||||||
<img src="https://img.shields.io/github/v/release/ByteDream/crunchyroll-go?style=flat-square" alt="Release">
|
<img src="https://img.shields.io/github/v/release/ByteDream/crunchy-cli?style=flat-square" alt="Release">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/gUWwekeNNg">
|
<a href="https://discord.gg/gUWwekeNNg">
|
||||||
<img src="https://img.shields.io/discord/915659846836162561?label=discord&style=flat-square" alt="Discord">
|
<img src="https://img.shields.io/discord/915659846836162561?label=discord&style=flat-square" alt="Discord">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/ByteDream/crunchyroll-go/actions/workflows/ci.yml">
|
<a href="https://github.com/ByteDream/crunchy-cli/actions/workflows/ci.yml">
|
||||||
<img src="https://github.com/ByteDream/crunchyroll-go/workflows/CI/badge.svg?style=flat" alt="CI">
|
<img src="https://github.com/ByteDream/crunchy-cli/workflows/CI/badge.svg?style=flat" alt="CI">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#%EF%B8%8F-cli">CLI 🖥️</a>
|
<a href="#%EF%B8%8F-cli">CLI 🖥️</a>
|
||||||
•
|
•
|
||||||
<a href="#-library">Library 📚</a>
|
|
||||||
•
|
|
||||||
<a href="#%EF%B8%8F-disclaimer">Disclaimer ☝️</a>
|
<a href="#%EF%B8%8F-disclaimer">Disclaimer ☝️</a>
|
||||||
•
|
•
|
||||||
<a href="#-license">License ⚖</a>
|
<a href="#-license">License ⚖</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
_This repo was former known as **crunchyroll-go** (which still exists but now contains only the library part) but got split up into two separate repositories to provide more flexibility.
|
||||||
|
See #39 for more information._
|
||||||
|
|
||||||
# 🖥️ CLI
|
# 🖥️ CLI
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
@ -46,37 +47,42 @@ A [Go](https://golang.org) library & cli for the undocumented [crunchyroll](http
|
||||||
|
|
||||||
## 💾 Get the executable
|
## 💾 Get the executable
|
||||||
|
|
||||||
- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchyroll-go/releases/latest) or get it from below:
|
- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchy-cli/releases/latest) or get it from below:
|
||||||
- [Linux (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_linux)
|
- [Linux (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_linux)
|
||||||
- [Windows (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_windows.exe)
|
- [Windows (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_windows.exe)
|
||||||
- [MacOS (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchyroll-go/crunchy-{tag}_darwin)
|
- [MacOS (x64)](https://smartrelease.bytedream.org/github/ByteDream/crunchy-cli/crunchy-{tag}_darwin)
|
||||||
- If you use Arch btw. or any other Linux distro which is based on Arch Linux, you can download the package via the [AUR](https://aur.archlinux.org/packages/crunchyroll-go/):
|
- If you use Arch btw. or any other Linux distro which is based on Arch Linux, you can download the package via the [AUR](https://aur.archlinux.org/packages/crunchyroll-go/):
|
||||||
```shell
|
```shell
|
||||||
$ yay -S crunchyroll-go
|
$ yay -S crunchyroll-go
|
||||||
```
|
```
|
||||||
- On Windows [scoop](https://scoop.sh/) can be used to install it (added by [@AdmnJ](https://github.com/AdmnJ)):
|
- <del>
|
||||||
|
|
||||||
|
On Windows [scoop](https://scoop.sh/) can be used to install it (added by [@AdmnJ](https://github.com/AdmnJ)):
|
||||||
```shell
|
```shell
|
||||||
$ scoop bucket add extras # <- in case you haven't added the extra repository already
|
$ scoop bucket add extras # <- in case you haven't added the extra repository already
|
||||||
$ scoop install crunchyroll-go
|
$ scoop install crunchyroll-go
|
||||||
```
|
```
|
||||||
- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/ByteDream/crunchyroll-go/releases/latest) (like Raspberry Pi or M1 Mac):
|
|
||||||
|
</del>
|
||||||
|
<i>Currently not working because the repo got renamed!</i>
|
||||||
|
- 🛠 Build it yourself. Must be done if your target platform is not covered by the [provided binaries](https://github.com/ByteDream/crunchy-cli/releases/latest) (like Raspberry Pi or M1 Mac):
|
||||||
- use `make` (requires `go` to be installed):
|
- use `make` (requires `go` to be installed):
|
||||||
```shell
|
```shell
|
||||||
$ git clone https://github.com/ByteDream/crunchyroll-go
|
$ git clone https://github.com/ByteDream/crunchy-cli
|
||||||
$ cd crunchyroll-go
|
$ cd crunchy-cli
|
||||||
$ make
|
$ make
|
||||||
$ sudo make install # <- only if you want to install it on your system
|
$ sudo make install # <- only if you want to install it on your system
|
||||||
```
|
```
|
||||||
- use `go`:
|
- use `go`:
|
||||||
```shell
|
```shell
|
||||||
$ git clone https://github.com/ByteDream/crunchyroll-go
|
$ git clone https://github.com/ByteDream/crunchy-cli
|
||||||
$ cd crunchyroll-go
|
$ cd crunchy-cli
|
||||||
$ go build -o crunchy cmd/crunchyroll-go/main.go
|
$ go build -o crunchy .
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Examples
|
## 📝 Examples
|
||||||
|
|
||||||
_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Cli), further usages and options are described there.
|
_Before reading_: Because of the huge functionality not all cases can be covered in the README. Make sure to check the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli), further usages and options are described there.
|
||||||
|
|
||||||
### Login
|
### Login
|
||||||
|
|
||||||
|
|
@ -163,7 +169,7 @@ The following flags can be (optional) passed to modify the [archive](#archive) p
|
||||||
| `-l` | `--language` | Audio locale which should be downloaded. Can be used multiple times. |
|
| `-l` | `--language` | Audio locale which should be downloaded. Can be used multiple times. |
|
||||||
| `-d` | `--directory` | Directory to download the video(s) to. |
|
| `-d` | `--directory` | Directory to download the video(s) to. |
|
||||||
| `-o` | `--output` | Name of the output file. |
|
| `-o` | `--output` | Name of the output file. |
|
||||||
| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Cli#archive) for more information. |
|
| `-m` | `--merge` | Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'. See the [wiki](https://github.com/ByteDream/crunchy-cli/wiki/Cli#archive) for more information. |
|
||||||
| `-c` | `--compress` | If is set, all output will be compresses into an archive. This flag sets the name of the compressed output file and the file ending specifies the compression algorithm (gzip, tar, zip are supported). |
|
| `-c` | `--compress` | If is set, all output will be compresses into an archive. This flag sets the name of the compressed output file and the file ending specifies the compression algorithm (gzip, tar, zip are supported). |
|
||||||
| `-r` | `--resolution` | The resolution of the video(s). `best` for best resolution, `worst` for worst. |
|
| `-r` | `--resolution` | The resolution of the video(s). `best` for best resolution, `worst` for worst. |
|
||||||
| `-g` | `--goroutines` | Sets how many parallel segment downloads should be used. |
|
| `-g` | `--goroutines` | Sets how many parallel segment downloads should be used. |
|
||||||
|
|
@ -198,18 +204,6 @@ These flags you can use across every sub-command:
|
||||||
| `-v` | Shows additional debug output. |
|
| `-v` | Shows additional debug output. |
|
||||||
| `-p` | Use a proxy to hide your ip / redirect your traffic. |
|
| `-p` | Use a proxy to hide your ip / redirect your traffic. |
|
||||||
|
|
||||||
# 📚 Library
|
|
||||||
|
|
||||||
Download the library via `go get`
|
|
||||||
|
|
||||||
```shell
|
|
||||||
$ go get github.com/ByteDream/crunchyroll-go/v3
|
|
||||||
```
|
|
||||||
|
|
||||||
The documentation is available on [pkg.go.dev](https://pkg.go.dev/github.com/ByteDream/crunchyroll-go/v3).
|
|
||||||
|
|
||||||
Examples how to use the library and some features of it are described in the [wiki](https://github.com/ByteDream/crunchyroll-go/wiki/Library).
|
|
||||||
|
|
||||||
# ☝️ Disclaimer
|
# ☝️ Disclaimer
|
||||||
|
|
||||||
This tool is **ONLY** meant to be used for private purposes. To use this tool you need crunchyroll premium anyway, so there is no reason why rip and share the episodes.
|
This tool is **ONLY** meant to be used for private purposes. To use this tool you need crunchyroll premium anyway, so there is no reason why rip and share the episodes.
|
||||||
|
|
@ -218,4 +212,4 @@ This tool is **ONLY** meant to be used for private purposes. To use this tool yo
|
||||||
|
|
||||||
# ⚖ License
|
# ⚖ License
|
||||||
|
|
||||||
This project is licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0) - see the [LICENSE](LICENSE) file for more details.
|
This project is licensed under the GNU General Public License v3.0 (GPL-3.0) - see the [LICENSE](LICENSE) file for more details.
|
||||||
|
|
|
||||||
171
account.go
171
account.go
|
|
@ -1,171 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Account returns information about the currently logged in crunchyroll account.
|
|
||||||
func (c *Crunchyroll) Account() (*Account, error) {
|
|
||||||
resp, err := c.request("https://beta.crunchyroll.com/accounts/v1/me", http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
account := &Account{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'me' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err = c.request("https://beta.crunchyroll.com/accounts/v1/me/profile", http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&account); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'profile' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account contains information about a crunchyroll account.
|
|
||||||
type Account struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
AccountID string `json:"account_id"`
|
|
||||||
ExternalID string `json:"external_id"`
|
|
||||||
EmailVerified bool `json:"email_verified"`
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
CrBetaOptIn bool `json:"cr_beta_opt_in"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
MatureContentFlagManga string `json:"mature_content_flag_manga"`
|
|
||||||
MaturityRating string `json:"maturity_rating"`
|
|
||||||
OptOutAndroidInAppMarketing bool `json:"opt_out_android_in_app_marketing"`
|
|
||||||
OptOutFreeTrials bool `json:"opt_out_free_trials"`
|
|
||||||
OptOutNewMediaQueueUpdates bool `json:"opt_out_new_media_queue_updates"`
|
|
||||||
OptOutNewsletters bool `json:"opt_out_newsletters"`
|
|
||||||
OptOutPmUpdates bool `json:"opt_out_pm_updates"`
|
|
||||||
OptOutPromotionalUpdates bool `json:"opt_out_promotional_updates"`
|
|
||||||
OptOutQueueUpdates bool `json:"opt_out_queue_updates"`
|
|
||||||
OptOutStoreDeals bool `json:"opt_out_store_deals"`
|
|
||||||
PreferredCommunicationLanguage LOCALE `json:"preferred_communication_language"`
|
|
||||||
PreferredContentSubtitleLanguage LOCALE `json:"preferred_content_subtitle_language"`
|
|
||||||
QaUser bool `json:"qa_user"`
|
|
||||||
|
|
||||||
Username string `json:"username"`
|
|
||||||
Wallpaper *Wallpaper `json:"wallpaper"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePreferredEmailLanguage sets in which language emails should be received.
|
|
||||||
func (a *Account) UpdatePreferredEmailLanguage(language LOCALE) error {
|
|
||||||
err := a.updatePreferences("preferred_communication_language", string(language))
|
|
||||||
if err == nil {
|
|
||||||
a.PreferredCommunicationLanguage = language
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePreferredVideoSubtitleLanguage sets in which language default subtitles should be shown
|
|
||||||
func (a *Account) UpdatePreferredVideoSubtitleLanguage(language LOCALE) error {
|
|
||||||
err := a.updatePreferences("preferred_content_subtitle_language", string(language))
|
|
||||||
if err == nil {
|
|
||||||
a.PreferredContentSubtitleLanguage = language
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMatureVideoContent sets if mature video content / 18+ content should be shown
|
|
||||||
func (a *Account) UpdateMatureVideoContent(enabled bool) error {
|
|
||||||
if enabled {
|
|
||||||
return a.updatePreferences("maturity_rating", "M3")
|
|
||||||
} else {
|
|
||||||
return a.updatePreferences("maturity_rating", "M2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMatureMangaContent sets if mature manga content / 18+ content should be shown
|
|
||||||
func (a *Account) UpdateMatureMangaContent(enabled bool) error {
|
|
||||||
if enabled {
|
|
||||||
return a.updatePreferences("mature_content_flag_manga", "1")
|
|
||||||
} else {
|
|
||||||
return a.updatePreferences("mature_content_flag_manga", "0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Account) updatePreferences(name, value string) error {
|
|
||||||
endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile"
|
|
||||||
body, _ := json.Marshal(map[string]string{name: value})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = a.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword changes the password for the current account.
|
|
||||||
func (a *Account) ChangePassword(currentPassword, newPassword string) error {
|
|
||||||
endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials"
|
|
||||||
body, _ := json.Marshal(map[string]string{"accountId": a.AccountID, "current_password": currentPassword, "new_password": newPassword})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = a.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangeEmail changes the email address for the current account.
|
|
||||||
func (a *Account) ChangeEmail(currentPassword, newEmail string) error {
|
|
||||||
endpoint := "https://beta.crunchyroll.com/accounts/v1/me/credentials"
|
|
||||||
body, _ := json.Marshal(map[string]string{"current_password": currentPassword, "email": newEmail})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = a.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// AvailableWallpapers returns all available wallpapers which can be set as profile wallpaper.
|
|
||||||
func (a *Account) AvailableWallpapers() (w []*Wallpaper, err error) {
|
|
||||||
endpoint := "https://beta.crunchyroll.com/assets/v1/wallpaper"
|
|
||||||
resp, err := a.crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]any
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
err = decodeMapToStruct(jsonBody["items"].([]any), &w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangeWallpaper changes the profile wallpaper of the current user. Use AvailableWallpapers
|
|
||||||
// to get all available ones.
|
|
||||||
func (a *Account) ChangeWallpaper(wallpaper *Wallpaper) error {
|
|
||||||
endpoint := "https://beta.crunchyroll.com/accounts/v1/me/profile"
|
|
||||||
body, _ := json.Marshal(map[string]string{"wallpaper": string(*wallpaper)})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = a.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
65
category.go
65
category.go
|
|
@ -1,65 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Categories returns all available categories and possible subcategories.
|
|
||||||
func (c *Crunchyroll) Categories(includeSubcategories bool) (ca []*Category, err error) {
|
|
||||||
tenantCategoriesEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/tenant_categories?include_subcategories=%t&locale=%s",
|
|
||||||
includeSubcategories, c.Locale)
|
|
||||||
resp, err := c.request(tenantCategoriesEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'tenant_categories' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
category := &Category{}
|
|
||||||
if err := decodeMapToStruct(item, category); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ca = append(ca, category)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ca, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category contains all information about a category.
|
|
||||||
type Category struct {
|
|
||||||
Category string `json:"tenant_category"`
|
|
||||||
|
|
||||||
SubCategories []struct {
|
|
||||||
Category string `json:"tenant_category"`
|
|
||||||
ParentCategory string `json:"parent_category"`
|
|
||||||
|
|
||||||
Localization struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Locale LOCALE `json:"locale"`
|
|
||||||
} `json:"localization"`
|
|
||||||
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
} `json:"sub_categories"`
|
|
||||||
|
|
||||||
Images struct {
|
|
||||||
Background []Image `json:"background"`
|
|
||||||
Low []Image `json:"low"`
|
|
||||||
} `json:"images"`
|
|
||||||
|
|
||||||
Localization struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Locale LOCALE `json:"locale"`
|
|
||||||
} `json:"localization"`
|
|
||||||
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/ByteDream/crunchyroll-go/v3/cmd/crunchyroll-go/cmd"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cmd.Execute()
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
|
@ -188,7 +188,7 @@ func archive(urls []string) error {
|
||||||
out.StopProgress("Failed to parse url %d", i+1)
|
out.StopProgress("Failed to parse url %d", i+1)
|
||||||
if crunchy.Config.Premium {
|
if crunchy.Config.Premium {
|
||||||
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
|
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
|
||||||
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
|
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -134,7 +134,7 @@ func download(urls []string) error {
|
||||||
out.StopProgress("Failed to parse url %d", i+1)
|
out.StopProgress("Failed to parse url %d", i+1)
|
||||||
if crunchy.Config.Premium {
|
if crunchy.Config.Premium {
|
||||||
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
|
out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " +
|
||||||
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information")
|
"try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -122,13 +122,13 @@ func loginCredentials(user, password string) error {
|
||||||
credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
|
credentials = []byte(fmt.Sprintf("%s\n%s", user, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
|
os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
|
||||||
if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), credentials, 0600); err != nil {
|
if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), credentials, 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !loginEncryptFlag {
|
if !loginEncryptFlag {
|
||||||
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+
|
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+
|
||||||
"To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
|
"To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchy-cli", "crunchy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,11 +157,11 @@ func loginSessionID(sessionID string) error {
|
||||||
if configDir, err := os.UserConfigDir(); err != nil {
|
if configDir, err := os.UserConfigDir(); err != nil {
|
||||||
return fmt.Errorf("could not save credentials persistent: %w", err)
|
return fmt.Errorf("could not save credentials persistent: %w", err)
|
||||||
} else {
|
} else {
|
||||||
os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
|
os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
|
||||||
if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
|
if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(c.EtpRt), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
|
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
|
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil {
|
||||||
|
|
@ -187,11 +187,11 @@ func loginEtpRt(etpRt string) error {
|
||||||
if configDir, err := os.UserConfigDir(); err != nil {
|
if configDir, err := os.UserConfigDir(); err != nil {
|
||||||
return fmt.Errorf("could not save credentials persistent: %w", err)
|
return fmt.Errorf("could not save credentials persistent: %w", err)
|
||||||
} else {
|
} else {
|
||||||
os.MkdirAll(filepath.Join(configDir, "crunchyroll-go"), 0755)
|
os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755)
|
||||||
if err = os.WriteFile(filepath.Join(configDir, "crunchyroll-go", "crunchy"), []byte(etpRt), 0600); err != nil {
|
if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(etpRt), 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchyroll-go", "crunchy"))
|
out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil {
|
if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -27,9 +27,9 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "crunchyroll-go",
|
Use: "crunchy-cli",
|
||||||
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/crunchy-cli/wiki",
|
||||||
|
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
|
@ -54,7 +54,7 @@ func init() {
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
|
rootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchyroll-go/%s", Version), "Useragent to do all request with")
|
rootCmd.PersistentFlags().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", Version), "Useragent to do all request with")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
|
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
|
||||||
|
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -38,7 +38,7 @@ func init() {
|
||||||
func update() error {
|
func update() error {
|
||||||
var release map[string]interface{}
|
var release map[string]interface{}
|
||||||
|
|
||||||
resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchyroll-go/releases/latest")
|
resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -80,20 +80,20 @@ func update() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Info("A new version is available (%s): https://github.com/ByteDream/crunchyroll-go/releases/tag/v%s", releaseVersion, releaseVersion)
|
out.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion)
|
||||||
|
|
||||||
if updateInstallFlag {
|
if updateInstallFlag {
|
||||||
if runtime.GOARCH != "amd64" {
|
if runtime.GOARCH != "amd64" {
|
||||||
return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
|
return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
|
||||||
"You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOARCH)
|
"You have to update manually (https://github.com/ByteDream/crunchy-cli)", runtime.GOARCH)
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadFile string
|
var downloadFile string
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "linux":
|
case "linux":
|
||||||
yayCommand := exec.Command("pacman -Q crunchyroll-go")
|
yayCommand := exec.Command("pacman -Q crunchy-cli")
|
||||||
if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
|
if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
|
||||||
out.Info("crunchyroll-go was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
|
out.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion)
|
downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion)
|
||||||
|
|
@ -103,7 +103,7 @@ func update() error {
|
||||||
downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
|
downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
|
return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
|
||||||
"You have to update manually (https://github.com/ByteDream/crunchyroll-go)", runtime.GOOS)
|
"You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.SetProgress("Updating executable %s", os.Args[0])
|
out.SetProgress("Updating executable %s", os.Args[0])
|
||||||
|
|
@ -119,7 +119,7 @@ func update() error {
|
||||||
}
|
}
|
||||||
defer executeFile.Close()
|
defer executeFile.Close()
|
||||||
|
|
||||||
resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchyroll-go/releases/download/v%s/%s", releaseVersion, downloadFile))
|
resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
|
|
@ -161,7 +161,7 @@ func loadCrunchy() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if configDir, err := os.UserConfigDir(); err == nil {
|
if configDir, err := os.UserConfigDir(); err == nil {
|
||||||
persistentFilePath := filepath.Join(configDir, "crunchyroll-go", "crunchy")
|
persistentFilePath := filepath.Join(configDir, "crunchy-cli", "crunchy")
|
||||||
if _, statErr := os.Stat(persistentFilePath); statErr == nil {
|
if _, statErr := os.Stat(persistentFilePath); statErr == nil {
|
||||||
body, err := os.ReadFile(persistentFilePath)
|
body, err := os.ReadFile(persistentFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package cmd
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
285
comment.go
285
comment.go
|
|
@ -1,285 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Comment struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
EpisodeID string `json:"episode_id"`
|
|
||||||
|
|
||||||
CommentID string `json:"comment_id"`
|
|
||||||
DomainID string `json:"domain_id"`
|
|
||||||
|
|
||||||
GuestbookKey string `json:"guestbook_key"`
|
|
||||||
|
|
||||||
User struct {
|
|
||||||
UserKey string `json:"user_key"`
|
|
||||||
UserAttributes struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Avatar struct {
|
|
||||||
Locked []Image `json:"locked"`
|
|
||||||
Unlocked []Image `json:"unlocked"`
|
|
||||||
} `json:"avatar"`
|
|
||||||
} `json:"user_attributes"`
|
|
||||||
UserFlags []any `json:"user_flags"`
|
|
||||||
} `json:"user"`
|
|
||||||
|
|
||||||
Message string `json:"message"`
|
|
||||||
ParentCommentID int `json:"parent_comment_id"`
|
|
||||||
|
|
||||||
Locale LOCALE `json:"locale"`
|
|
||||||
|
|
||||||
UserVotes []string `json:"user_votes"`
|
|
||||||
Flags []string `json:"flags"`
|
|
||||||
Votes struct {
|
|
||||||
Inappropriate int `json:"inappropriate"`
|
|
||||||
Like int `json:"like"`
|
|
||||||
Spoiler int `json:"spoiler"`
|
|
||||||
} `json:"votes"`
|
|
||||||
|
|
||||||
DeleteReason any `json:"delete_reason"`
|
|
||||||
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
|
|
||||||
IsOwner bool `json:"is_owner"`
|
|
||||||
RepliesCount int `json:"replies_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete deleted the current comment. Works only if the user has written the comment.
|
|
||||||
func (c *Comment) Delete() error {
|
|
||||||
if !c.IsOwner {
|
|
||||||
return fmt.Errorf("cannot delete, user is not the comment author")
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
|
|
||||||
resp, err := c.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// the api returns a new comment object when modifying it.
|
|
||||||
// hopefully this does not change
|
|
||||||
json.NewDecoder(resp.Body).Decode(c)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSpoiler returns if the comment is marked as spoiler or not.
|
|
||||||
func (c *Comment) IsSpoiler() bool {
|
|
||||||
for _, flag := range c.Flags {
|
|
||||||
if flag == "spoiler" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkAsSpoiler marks the current comment as spoiler. Works only if the user has written the comment,
|
|
||||||
// and it isn't already marked as spoiler.
|
|
||||||
func (c *Comment) MarkAsSpoiler() error {
|
|
||||||
if !c.IsOwner {
|
|
||||||
return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
|
|
||||||
} else if c.votedAs("spoiler") {
|
|
||||||
return fmt.Errorf("comment is already marked as spoiler")
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string][]string{"add": {"spoiler"}})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err := c.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
json.NewDecoder(resp.Body).Decode(c)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarkAsSpoiler unmarks the current comment as spoiler. Works only if the user has written the comment,
|
|
||||||
// and it is already marked as spoiler.
|
|
||||||
func (c *Comment) UnmarkAsSpoiler() error {
|
|
||||||
if !c.IsOwner {
|
|
||||||
return fmt.Errorf("cannot mark as spoiler, user is not the comment author")
|
|
||||||
} else if !c.votedAs("spoiler") {
|
|
||||||
return fmt.Errorf("comment is not marked as spoiler")
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/flags?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string][]string{"remove": {"spoiler"}})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err := c.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
json.NewDecoder(resp.Body).Decode(c)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Like likes the comment. Works only if the user hasn't already liked it.
|
|
||||||
func (c *Comment) Like() error {
|
|
||||||
if err := c.vote("like", "liked"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.Votes.Like += 1
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Liked returns if the user has liked the comment.
|
|
||||||
func (c *Comment) Liked() bool {
|
|
||||||
return c.votedAs("liked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveLike removes the like from the comment. Works only if the user has liked it.
|
|
||||||
func (c *Comment) RemoveLike() error {
|
|
||||||
if err := c.unVote("like", "liked"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.Votes.Like -= 1
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply replies to the current comment.
|
|
||||||
func (c *Comment) Reply(message string, spoiler bool) (*Comment, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", c.EpisodeID, c.crunchy.Locale)
|
|
||||||
var flags []string
|
|
||||||
if spoiler {
|
|
||||||
flags = append(flags, "spoiler")
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(map[string]any{"locale": string(c.crunchy.Locale), "message": message, "flags": flags, "parent_id": c.CommentID})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err := c.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
reply := &Comment{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(reply); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replies shows all replies to the current comment.
|
|
||||||
func (c *Comment) Replies(page uint, size uint) ([]*Comment, error) {
|
|
||||||
if c.RepliesCount == 0 {
|
|
||||||
return []*Comment{}, nil
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/replies?page_size=%d&page=%d&locale=%s", c.EpisodeID, c.CommentID, size, page, c.Locale)
|
|
||||||
resp, err := c.crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]any
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
var comments []*Comment
|
|
||||||
if err = decodeMapToStruct(jsonBody["items"].([]any), &comments); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return comments, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report reports the comment. Only works if the comment hasn't been reported yet.
|
|
||||||
func (c *Comment) Report() error {
|
|
||||||
return c.vote("inappropriate", "reported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Comment) IsReported() bool {
|
|
||||||
return c.votedAs("reported")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveReport removes the report request from the comment. Only works if the user
|
|
||||||
// has reported the comment.
|
|
||||||
func (c *Comment) RemoveReport() error {
|
|
||||||
return c.unVote("inappropriate", "reported")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlagAsSpoiler sends a request to the user (and / or crunchyroll?) to mark the comment
|
|
||||||
// as spoiler. Only works if the comment hasn't been flagged as spoiler yet.
|
|
||||||
func (c *Comment) FlagAsSpoiler() error {
|
|
||||||
return c.vote("spoiler", "spoiler")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Comment) IsFlaggedAsSpoiler() bool {
|
|
||||||
return c.votedAs("spoiler")
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnflagAsSpoiler rewokes the request to the user (and / or crunchyroll?) to mark the
|
|
||||||
// comment as spoiler. Only works if the user has flagged the comment as spoiler.
|
|
||||||
func (c *Comment) UnflagAsSpoiler() error {
|
|
||||||
return c.unVote("spoiler", "spoiler")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Comment) votedAs(voteType string) bool {
|
|
||||||
for _, userVote := range c.UserVotes {
|
|
||||||
if userVote == voteType {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Comment) vote(voteType, readableName string) error {
|
|
||||||
if c.votedAs(voteType) {
|
|
||||||
return fmt.Errorf("comment is already marked as %s", readableName)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?locale=%s", c.EpisodeID, c.CommentID, c.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"vote_type": voteType})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = c.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.UserVotes = append(c.UserVotes, voteType)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Comment) unVote(voteType, readableName string) error {
|
|
||||||
for i, userVote := range c.UserVotes {
|
|
||||||
if userVote == voteType {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments/%s/votes?vote_type=%s&locale=%s", c.EpisodeID, c.CommentID, voteType, c.crunchy.Locale)
|
|
||||||
_, err := c.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.UserVotes = append(c.UserVotes[:i], c.UserVotes[i+1:]...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("comment is not marked as %s", readableName)
|
|
||||||
}
|
|
||||||
31
common.go
31
common.go
|
|
@ -1,31 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
type Image struct {
|
|
||||||
Height int `json:"height"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Panel struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
PromoTitle string `json:"promo_title"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Playback string `json:"playback"`
|
|
||||||
PromoDescription string `json:"promo_description"`
|
|
||||||
Images struct {
|
|
||||||
Thumbnail [][]Image `json:"thumbnail"`
|
|
||||||
PosterTall [][]Image `json:"poster_tall"`
|
|
||||||
PosterWide [][]Image `json:"poster_wide"`
|
|
||||||
} `json:"images"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
ChannelID string `json:"channel_id"`
|
|
||||||
Type WatchlistEntryType `json:"type"`
|
|
||||||
ExternalID string `json:"external_id"`
|
|
||||||
SlugTitle string `json:"slug_title"`
|
|
||||||
// not null if Type is WATCHLISTENTRYEPISODE
|
|
||||||
EpisodeMetadata *Episode `json:"episode_metadata"`
|
|
||||||
// not null if Type is WATCHLISTENTRYSERIES
|
|
||||||
SeriesMetadata *Series `json:"series_metadata"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
.TH crunchyroll-go 1 "21 March 2022" "crunchyroll-go" "Crunchyroll Downloader"
|
.TH crunchy-cli 1 "27 June 2022" "crunchy-cli" "Crunchyroll Cli Client"
|
||||||
|
|
||||||
.SH NAME
|
.SH NAME
|
||||||
crunchyroll-go - A cli for downloading videos and entire series from crunchyroll.
|
crunchy-cli - A cli for downloading videos and entire series from crunchyroll.
|
||||||
|
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
crunchyroll-go [\fB-h\fR] [\fB-p\fR \fIPROXY\fR] [\fB-q\fR] [\fB-v\fR]
|
crunchy-cli [\fB-h\fR] [\fB-p\fR \fIPROXY\fR] [\fB-q\fR] [\fB-v\fR]
|
||||||
.br
|
.br
|
||||||
crunchyroll-go help
|
crunchy-cli help
|
||||||
.br
|
.br
|
||||||
crunchyroll-go login [\fB--persistent\fR] [\fB--session-id\fR \fISESSION_ID\fR] [\fIusername\fR, \fIpassword\fR]
|
crunchy-cli login [\fB--persistent\fR] [\fB--session-id\fR \fISESSION_ID\fR] [\fIusername\fR, \fIpassword\fR]
|
||||||
.br
|
.br
|
||||||
crunchyroll-go download [\fB-a\fR \fIAUDIO\fR] [\fB-s\fR \fISUBTITLE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
|
crunchy-cli download [\fB-a\fR \fIAUDIO\fR] [\fB-s\fR \fISUBTITLE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
|
||||||
.br
|
.br
|
||||||
crunchyroll-go archive [\fB-l\fR \fILANGUAGE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-m\fR \fIMERGE BEHAVIOR\fR] [\fB-c\fR \fICOMPRESS\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
|
crunchy-cli archive [\fB-l\fR \fILANGUAGE\fR] [\fB-d\fR \fIDIRECTORY\fR] [\fB-o\fR \fIOUTPUT\fR] [\fB-m\fR \fIMERGE BEHAVIOR\fR] [\fB-c\fR \fICOMPRESS\fR] [\fB-r\fR \fIRESOLUTION\fR] [\fB-g\fR \fIGOROUTINES\fR] \fIURLs...\fR
|
||||||
.br
|
.br
|
||||||
crunchyroll-go update [\fB-i\fR \fIINSTALL\fR]
|
crunchy-cli update [\fB-i\fR \fIINSTALL\fR]
|
||||||
|
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.TP
|
.TP
|
||||||
With \fBcrunchyroll-go\fR you can easily download video and series from crunchyroll.
|
With \fBcrunchy-cli\fR you can easily download video and series from crunchyroll.
|
||||||
.TP
|
.TP
|
||||||
|
|
||||||
Note that you need an \fBcrunchyroll premium\fR account in order to use this tool!
|
Note that you need an \fBcrunchyroll premium\fR account in order to use this tool!
|
||||||
|
|
@ -167,27 +167,27 @@ The \fBS\fR, followed by the number indicates the season number, \fBE\fR, follow
|
||||||
.SH EXAMPLES
|
.SH EXAMPLES
|
||||||
Login via crunchyroll account email and password.
|
Login via crunchyroll account email and password.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go login user@example.com 12345678
|
$ crunchy-cli login user@example.com 12345678
|
||||||
|
|
||||||
Download a episode normally. Your system locale will be used for the video's audio.
|
Download a episode normally. Your system locale will be used for the video's audio.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go download https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
$ crunchy-cli download https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
||||||
|
|
||||||
Download a episode with 720p and name it to 'darling.mp4'. Note that you need \fBffmpeg\fR to save files which do not have '.ts' as file extension.
|
Download a episode with 720p and name it to 'darling.mp4'. Note that you need \fBffmpeg\fR to save files which do not have '.ts' as file extension.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go download -o "darling.mp4" -r 720p https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
$ crunchy-cli download -o "darling.mp4" -r 720p https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
||||||
|
|
||||||
Download a episode with japanese audio and american subtitles.
|
Download a episode with japanese audio and american subtitles.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go download -a ja-JP -s en-US https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E3-E5]
|
$ crunchy-cli download -a ja-JP -s en-US https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E3-E5]
|
||||||
|
|
||||||
Stores the episode in a .mkv file.
|
Stores the episode in a .mkv file.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go archive https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
$ crunchy-cli archive https://beta.crunchyroll.com/watch/GRDKJZ81Y/alone-and-lonesome
|
||||||
|
|
||||||
Downloads the first two episode of Darling in the FranXX and stores it compressed in a file.
|
Downloads the first two episode of Darling in the FranXX and stores it compressed in a file.
|
||||||
.br
|
.br
|
||||||
$ crunchyroll-go archive -c "ditf.tar.gz" https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E1-E2]
|
$ crunchy-cli archive -c "ditf.tar.gz" https://beta.crunchyroll.com/series/GY8VEQ95Y/darling-in-the-franxx[E1-E2]
|
||||||
|
|
||||||
.SH BUGS
|
.SH BUGS
|
||||||
If you notice any bug or want an enhancement, feel free to create a new issue or pull request in the GitHub repository.
|
If you notice any bug or want an enhancement, feel free to create a new issue or pull request in the GitHub repository.
|
||||||
|
|
@ -195,22 +195,21 @@ If you notice any bug or want an enhancement, feel free to create a new issue or
|
||||||
.SH AUTHOR
|
.SH AUTHOR
|
||||||
ByteDream
|
ByteDream
|
||||||
.br
|
.br
|
||||||
Source: https://github.com/ByteDream/crunchyroll-go
|
Source: https://github.com/ByteDream/crunchy-cli
|
||||||
|
|
||||||
.SH COPYRIGHT
|
.SH COPYRIGHT
|
||||||
Copyright (C) 2022 ByteDream
|
Copyright (C) 2022 ByteDream
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or
|
This program is free software: you can redistribute it and/or
|
||||||
modify it under the terms of the GNU Lesser General Public
|
modify it under the terms of the GNU General Public License
|
||||||
License as published by the Free Software Foundation; either
|
as published by the Free Software Foundation, either version 3
|
||||||
version 3 of the License, or (at your option) any later version.
|
of the License, or (at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
Lesser General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU Lesser General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program; if not, write to the Free Software Foundation,
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
||||||
|
|
||||||
177
crunchylists.go
177
crunchylists.go
|
|
@ -1,177 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Crunchylists returns a struct to control crunchylists.
|
|
||||||
func (c *Crunchyroll) Crunchylists() (*Crunchylists, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", c.Config.AccountID, c.Locale)
|
|
||||||
resp, err := c.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
crunchylists := &Crunchylists{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
json.NewDecoder(resp.Body).Decode(crunchylists)
|
|
||||||
for _, item := range crunchylists.Items {
|
|
||||||
item.crunchy = c
|
|
||||||
}
|
|
||||||
|
|
||||||
return crunchylists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Crunchylists struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
Items []*CrunchylistPreview `json:"items"`
|
|
||||||
TotalPublic int `json:"total_public"`
|
|
||||||
TotalPrivate int `json:"total_private"`
|
|
||||||
MaxPrivate int `json:"max_private"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new crunchylist with the given name. Duplicate names for lists are allowed.
|
|
||||||
func (cl *Crunchylists) Create(name string) (*Crunchylist, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s?locale=%s", cl.crunchy.Config.AccountID, cl.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"title": name})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err := cl.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
return crunchylistFromID(cl.crunchy, jsonBody["list_id"].(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
type CrunchylistPreview struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
ListID string `json:"list_id"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
ModifiedAt time.Time `json:"modified_at"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crunchylist returns the belonging Crunchylist struct.
|
|
||||||
func (clp *CrunchylistPreview) Crunchylist() (*Crunchylist, error) {
|
|
||||||
return crunchylistFromID(clp.crunchy, clp.ListID)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Crunchylist struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
ID string `json:"id"`
|
|
||||||
|
|
||||||
Max int `json:"max"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
ModifiedAt time.Time `json:"modified_at"`
|
|
||||||
Items []*CrunchylistItem `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSeries adds a series.
|
|
||||||
func (cl *Crunchylist) AddSeries(series *Series) error {
|
|
||||||
return cl.AddSeriesFromID(series.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSeriesFromID adds a series from its id
|
|
||||||
func (cl *Crunchylist) AddSeriesFromID(id string) error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"content_id": id})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = cl.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSeries removes a series
|
|
||||||
func (cl *Crunchylist) RemoveSeries(series *Series) error {
|
|
||||||
return cl.RemoveSeriesFromID(series.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSeriesFromID removes a series by its id
|
|
||||||
func (cl *Crunchylist) RemoveSeriesFromID(id string) error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, id, cl.crunchy.Locale)
|
|
||||||
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete deleted the current crunchylist.
|
|
||||||
func (cl *Crunchylist) Delete() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
|
|
||||||
_, err := cl.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename renames the current crunchylist.
|
|
||||||
func (cl *Crunchylist) Rename(name string) error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", cl.crunchy.Config.AccountID, cl.ID, cl.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"title": name})
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = cl.crunchy.requestFull(req)
|
|
||||||
if err == nil {
|
|
||||||
cl.Title = name
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func crunchylistFromID(crunchy *Crunchyroll, id string) (*Crunchylist, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s?locale=%s", crunchy.Config.AccountID, id, crunchy.Locale)
|
|
||||||
resp, err := crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
crunchyList := &Crunchylist{
|
|
||||||
crunchy: crunchy,
|
|
||||||
ID: id,
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(crunchyList); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, item := range crunchyList.Items {
|
|
||||||
item.crunchy = crunchy
|
|
||||||
}
|
|
||||||
return crunchyList, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type CrunchylistItem struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
ListID string `json:"list_id"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
ModifiedAt time.Time `json:"modified_at"`
|
|
||||||
Panel Panel `json:"panel"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes the current item from its crunchylist.
|
|
||||||
func (cli *CrunchylistItem) Remove() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/custom-lists/%s/%s/%s", cli.crunchy.Config.AccountID, cli.ListID, cli.ID)
|
|
||||||
_, err := cli.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
327
crunchyroll.go
327
crunchyroll.go
|
|
@ -1,327 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LOCALE represents a locale / language.
|
|
||||||
type LOCALE string
|
|
||||||
|
|
||||||
const (
|
|
||||||
JP LOCALE = "ja-JP"
|
|
||||||
US = "en-US"
|
|
||||||
LA = "es-419"
|
|
||||||
ES = "es-ES"
|
|
||||||
FR = "fr-FR"
|
|
||||||
PT = "pt-PT"
|
|
||||||
BR = "pt-BR"
|
|
||||||
IT = "it-IT"
|
|
||||||
DE = "de-DE"
|
|
||||||
RU = "ru-RU"
|
|
||||||
AR = "ar-SA"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MediaType represents a media type.
|
|
||||||
type MediaType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MediaTypeSeries MediaType = "series"
|
|
||||||
MediaTypeMovie = "movie_listing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type loginResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
AccountID string `json:"account_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginWithCredentials logs in via crunchyroll username or email and password.
|
|
||||||
func LoginWithCredentials(user string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
|
|
||||||
endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
|
|
||||||
values := url.Values{}
|
|
||||||
values.Set("username", user)
|
|
||||||
values.Set("password", password)
|
|
||||||
values.Set("grant_type", "password")
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(values.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Basic aHJobzlxM2F3dnNrMjJ1LXRzNWE6cHROOURteXRBU2Z6QjZvbXVsSzh6cUxzYTczVE1TY1k=")
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
resp, err := request(req, client)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var loginResp loginResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&loginResp)
|
|
||||||
|
|
||||||
var etpRt string
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
if cookie.Name == "etp_rt" {
|
|
||||||
etpRt = cookie.Value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return postLogin(loginResp, etpRt, locale, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginWithSessionID logs in via a crunchyroll session id.
|
|
||||||
// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com.
|
|
||||||
//
|
|
||||||
// Deprecated: Login via session id caused some trouble in the past (e.g. #15 or #30) which resulted in
|
|
||||||
// login not working. Use LoginWithEtpRt instead. EtpRt practically the crunchyroll beta equivalent to
|
|
||||||
// a session id.
|
|
||||||
// The method will stay in the library until session id login is removed completely or login with it
|
|
||||||
// does not work for a longer period of time.
|
|
||||||
func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
|
|
||||||
sessionID)
|
|
||||||
resp, err := client.Get(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("failed to start session: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsonBody map[string]any
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse start session with session id response: %w", err)
|
|
||||||
}
|
|
||||||
if isError, ok := jsonBody["error"]; ok && isError.(bool) {
|
|
||||||
return nil, fmt.Errorf("invalid session id (%s): %s", jsonBody["message"].(string), jsonBody["code"])
|
|
||||||
}
|
|
||||||
|
|
||||||
var etpRt string
|
|
||||||
for _, cookie := range resp.Cookies() {
|
|
||||||
if cookie.Name == "etp_rt" {
|
|
||||||
etpRt = cookie.Value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return LoginWithEtpRt(etpRt, locale, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginWithEtpRt logs in via the crunchyroll etp rt cookie. This cookie is the crunchyroll beta
|
|
||||||
// equivalent to the classic session id.
|
|
||||||
// The etp_rt cookie is automatically set when visiting https://beta.crunchyroll.com. Note that you
|
|
||||||
// need a crunchyroll account to access it.
|
|
||||||
func LoginWithEtpRt(etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
|
|
||||||
endpoint := "https://beta-api.crunchyroll.com/auth/v1/token"
|
|
||||||
grantType := url.Values{}
|
|
||||||
grantType.Set("grant_type", "etp_rt_cookie")
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(grantType.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.AddCookie(&http.Cookie{
|
|
||||||
Name: "etp_rt",
|
|
||||||
Value: etpRt,
|
|
||||||
})
|
|
||||||
resp, err := request(req, client)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginResp loginResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&loginResp)
|
|
||||||
|
|
||||||
return postLogin(loginResp, etpRt, locale, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postLogin(loginResp loginResponse, etpRt string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
|
|
||||||
crunchy := &Crunchyroll{
|
|
||||||
Client: client,
|
|
||||||
Context: context.Background(),
|
|
||||||
Locale: locale,
|
|
||||||
EtpRt: etpRt,
|
|
||||||
cache: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
crunchy.Config.TokenType = loginResp.TokenType
|
|
||||||
crunchy.Config.AccessToken = loginResp.AccessToken
|
|
||||||
crunchy.Config.AccountID = loginResp.AccountID
|
|
||||||
crunchy.Config.CountryCode = loginResp.Country
|
|
||||||
|
|
||||||
var jsonBody map[string]any
|
|
||||||
|
|
||||||
endpoint := "https://beta-api.crunchyroll.com/index/v2"
|
|
||||||
resp, err := crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
cms := jsonBody["cms"].(map[string]any)
|
|
||||||
crunchy.Config.Premium = strings.HasSuffix(crunchy.Config.Bucket, "crunchyroll")
|
|
||||||
// / is trimmed so that urls which require it must be in .../{bucket}/... like format.
|
|
||||||
// this just looks cleaner
|
|
||||||
crunchy.Config.Bucket = strings.TrimPrefix(cms["bucket"].(string), "/")
|
|
||||||
crunchy.Config.Policy = cms["policy"].(string)
|
|
||||||
crunchy.Config.Signature = cms["signature"].(string)
|
|
||||||
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
|
|
||||||
|
|
||||||
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me"
|
|
||||||
resp, err = crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
crunchy.Config.ExternalID = jsonBody["external_id"].(string)
|
|
||||||
|
|
||||||
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/profile"
|
|
||||||
resp, err = crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string)
|
|
||||||
|
|
||||||
return crunchy, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Crunchyroll struct {
|
|
||||||
// Client is the http.Client to perform all requests over.
|
|
||||||
Client *http.Client
|
|
||||||
// Context can be used to stop requests with Client and is context.Background by default.
|
|
||||||
Context context.Context
|
|
||||||
// Locale specifies in which language all results should be returned / requested.
|
|
||||||
Locale LOCALE
|
|
||||||
// EtpRt is the crunchyroll beta equivalent to a session id (prior SessionID field in
|
|
||||||
// this struct in v2 and below).
|
|
||||||
EtpRt string
|
|
||||||
|
|
||||||
// Config stores parameters which are needed by some api calls.
|
|
||||||
Config struct {
|
|
||||||
TokenType string
|
|
||||||
AccessToken string
|
|
||||||
|
|
||||||
Bucket string
|
|
||||||
|
|
||||||
CountryCode string
|
|
||||||
Premium bool
|
|
||||||
Channel string
|
|
||||||
Policy string
|
|
||||||
Signature string
|
|
||||||
KeyPairID string
|
|
||||||
AccountID string
|
|
||||||
ExternalID string
|
|
||||||
MaturityRating string
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cache is true, internal caching is enabled.
|
|
||||||
cache bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateSession logs the user out which invalidates the current session.
|
|
||||||
// You have to call a login method again and create a new Crunchyroll instance
|
|
||||||
// if you want to perform any further actions since this instance is not usable
|
|
||||||
// anymore after calling this.
|
|
||||||
func (c *Crunchyroll) InvalidateSession() error {
|
|
||||||
endpoint := "https://crunchyroll.com/logout"
|
|
||||||
_, err := c.request(endpoint, http.MethodGet)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsCaching returns if data gets cached or not.
|
|
||||||
// See SetCaching for more information.
|
|
||||||
func (c *Crunchyroll) IsCaching() bool {
|
|
||||||
return c.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCaching enables or disables internal caching of requests made.
|
|
||||||
// Caching is enabled by default.
|
|
||||||
// If it is disabled the already cached data still gets called.
|
|
||||||
// The best way to prevent this is to create a complete new Crunchyroll struct.
|
|
||||||
func (c *Crunchyroll) SetCaching(caching bool) {
|
|
||||||
c.cache = caching
|
|
||||||
}
|
|
||||||
|
|
||||||
// request is a base function which handles simple api requests.
|
|
||||||
func (c *Crunchyroll) request(endpoint string, method string) (*http.Response, error) {
|
|
||||||
req, err := http.NewRequest(method, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return c.requestFull(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestFull is a base function which handles full user controlled api requests.
|
|
||||||
func (c *Crunchyroll) requestFull(req *http.Request) (*http.Response, error) {
|
|
||||||
req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
|
|
||||||
|
|
||||||
return request(req, c.Client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func request(req *http.Request, client *http.Client) (*http.Response, error) {
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
io.Copy(&buf, resp.Body)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
defer func() {
|
|
||||||
resp.Body = io.NopCloser(&buf)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if buf.Len() != 0 {
|
|
||||||
var errMap map[string]any
|
|
||||||
|
|
||||||
if err = json.Unmarshal(buf.Bytes(), &errMap); err != nil {
|
|
||||||
return nil, &RequestError{Response: resp, Message: fmt.Sprintf("invalid json response: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if val, ok := errMap["error"]; ok {
|
|
||||||
if errorAsString, ok := val.(string); ok {
|
|
||||||
if code, ok := errMap["code"].(string); ok {
|
|
||||||
return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", errorAsString, code)}
|
|
||||||
}
|
|
||||||
return nil, &RequestError{Response: resp, Message: errorAsString}
|
|
||||||
} else if errorAsBool, ok := val.(bool); ok && errorAsBool {
|
|
||||||
if msg, ok := errMap["message"].(string); ok {
|
|
||||||
return nil, &RequestError{Response: resp, Message: msg}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if _, ok := errMap["code"]; ok {
|
|
||||||
if errContext, ok := errMap["context"].([]any); ok && len(errContext) > 0 {
|
|
||||||
errField := errContext[0].(map[string]any)
|
|
||||||
var code string
|
|
||||||
if code, ok = errField["message"].(string); !ok {
|
|
||||||
code = errField["code"].(string)
|
|
||||||
}
|
|
||||||
return nil, &RequestError{Response: resp, Message: fmt.Sprintf("%s - %s", code, errField["field"].(string))}
|
|
||||||
} else if errMessage, ok := errMap["message"].(string); ok {
|
|
||||||
return nil, &RequestError{Response: resp, Message: errMessage}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, &RequestError{Response: resp, Message: resp.Status}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
395
downloader.go
395
downloader.go
|
|
@ -1,395 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"fmt"
|
|
||||||
"github.com/grafov/m3u8"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewDownloader creates a downloader with default settings which should
|
|
||||||
// fit the most needs.
|
|
||||||
func NewDownloader(context context.Context, writer io.Writer, goroutines int, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File) error) Downloader {
|
|
||||||
tmp, _ := os.MkdirTemp("", "crunchy_")
|
|
||||||
|
|
||||||
return Downloader{
|
|
||||||
Writer: writer,
|
|
||||||
TempDir: tmp,
|
|
||||||
DeleteTempAfter: true,
|
|
||||||
Context: context,
|
|
||||||
Goroutines: goroutines,
|
|
||||||
OnSegmentDownload: onSegmentDownload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downloader is used to download Format's
|
|
||||||
type Downloader struct {
|
|
||||||
// The output is all written to Writer.
|
|
||||||
Writer io.Writer
|
|
||||||
|
|
||||||
// TempDir is the directory where the temporary segment files should be stored.
|
|
||||||
// The files will be placed directly into the root of the directory.
|
|
||||||
// If empty a random temporary directory on the system's default tempdir
|
|
||||||
// will be created.
|
|
||||||
// If the directory does not exist, it will be created.
|
|
||||||
TempDir string
|
|
||||||
// If DeleteTempAfter is true, the temp directory gets deleted afterwards.
|
|
||||||
// Note that in case of a hard signal exit (os.Interrupt, ...) the directory
|
|
||||||
// will NOT be deleted. In such situations try to catch the signal and
|
|
||||||
// cancel Context.
|
|
||||||
DeleteTempAfter bool
|
|
||||||
|
|
||||||
// Context to control the download process with.
|
|
||||||
// There is a tiny delay when canceling the context and the actual stop of the
|
|
||||||
// process. So it is not recommend stopping the program immediately after calling
|
|
||||||
// the cancel function. It's better when canceling it and then exit the program
|
|
||||||
// when Format.Download throws an error. See the signal handling section in
|
|
||||||
// cmd/crunchyroll-go/cmd/download.go for an example.
|
|
||||||
Context context.Context
|
|
||||||
|
|
||||||
// Goroutines is the number of goroutines to download segments with.
|
|
||||||
Goroutines int
|
|
||||||
|
|
||||||
// A method to call when a segment was downloaded.
|
|
||||||
// Note that the segments are downloaded asynchronously (depending on the count of
|
|
||||||
// Goroutines) and the function gets called asynchronously too, so for example it is
|
|
||||||
// first called on segment 1, then segment 254, then segment 3 and so on.
|
|
||||||
OnSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File) error
|
|
||||||
// If LockOnSegmentDownload is true, only one OnSegmentDownload function can be called at
|
|
||||||
// once. Normally (because of the use of goroutines while downloading) multiple could get
|
|
||||||
// called simultaneously.
|
|
||||||
LockOnSegmentDownload bool
|
|
||||||
|
|
||||||
// If FFmpegOpts is not nil, ffmpeg will be used to merge and convert files.
|
|
||||||
// The given opts will be used as ffmpeg parameters while merging.
|
|
||||||
//
|
|
||||||
// If Writer is *os.File and -f (which sets the output format) is not specified, the output
|
|
||||||
// format will be retrieved by its file ending. If this is not the case and -f is not given,
|
|
||||||
// the output format will be mpegts / mpeg transport stream.
|
|
||||||
// Execute 'ffmpeg -muxers' to see all available output formats.
|
|
||||||
FFmpegOpts []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// download downloads the given format.
|
|
||||||
func (d Downloader) download(format *Format) error {
|
|
||||||
if err := format.InitVideo(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(d.TempDir); os.IsNotExist(err) {
|
|
||||||
if err = os.Mkdir(d.TempDir, 0700); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.DeleteTempAfter {
|
|
||||||
defer os.RemoveAll(d.TempDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := d.downloadSegments(format)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.FFmpegOpts == nil {
|
|
||||||
return d.mergeSegments(files)
|
|
||||||
} else {
|
|
||||||
return d.mergeSegmentsFFmpeg(files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeSegments reads every file in tempDir and writes their content to Downloader.Writer.
|
|
||||||
// The given output file gets created or overwritten if already existing.
|
|
||||||
func (d Downloader) mergeSegments(files []string) error {
|
|
||||||
for _, file := range files {
|
|
||||||
select {
|
|
||||||
case <-d.Context.Done():
|
|
||||||
return d.Context.Err()
|
|
||||||
default:
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err = io.Copy(d.Writer, f); err != nil {
|
|
||||||
f.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeSegmentsFFmpeg reads every file in tempDir and merges their content to the outputFile
|
|
||||||
// with ffmpeg (https://ffmpeg.org/).
|
|
||||||
// The given output file gets created or overwritten if already existing.
|
|
||||||
func (d Downloader) mergeSegmentsFFmpeg(files []string) error {
|
|
||||||
list, err := os.Create(filepath.Join(d.TempDir, "list.txt"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if _, err = fmt.Fprintf(list, "file '%s'\n", file); err != nil {
|
|
||||||
list.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list.Close()
|
|
||||||
|
|
||||||
// predefined options ... custom options ... predefined output filename
|
|
||||||
command := []string{
|
|
||||||
"-y",
|
|
||||||
"-f", "concat",
|
|
||||||
"-safe", "0",
|
|
||||||
"-i", list.Name(),
|
|
||||||
"-c", "copy",
|
|
||||||
}
|
|
||||||
if d.FFmpegOpts != nil {
|
|
||||||
command = append(command, d.FFmpegOpts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmpfile string
|
|
||||||
if _, ok := d.Writer.(*io.PipeWriter); !ok {
|
|
||||||
if file, ok := d.Writer.(*os.File); ok {
|
|
||||||
tmpfile = file.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filepath.Ext(tmpfile) == "" {
|
|
||||||
// checks if the -f flag is set (overwrites the output format)
|
|
||||||
var hasF bool
|
|
||||||
for _, opts := range d.FFmpegOpts {
|
|
||||||
if strings.TrimSpace(opts) == "-f" {
|
|
||||||
hasF = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasF {
|
|
||||||
command = append(command, "-f", "matroska")
|
|
||||||
f, err := os.CreateTemp(d.TempDir, "")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
tmpfile = f.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
command = append(command, tmpfile)
|
|
||||||
|
|
||||||
var errBuf bytes.Buffer
|
|
||||||
cmd := exec.CommandContext(d.Context, "ffmpeg",
|
|
||||||
command...)
|
|
||||||
cmd.Stderr = &errBuf
|
|
||||||
|
|
||||||
if err = cmd.Run(); err != nil {
|
|
||||||
if errBuf.Len() > 0 {
|
|
||||||
return fmt.Errorf(errBuf.String())
|
|
||||||
} else {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f, ok := d.Writer.(*os.File); !ok || f.Name() != tmpfile {
|
|
||||||
var file *os.File
|
|
||||||
if file, err = os.Open(tmpfile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
_, err = io.Copy(d.Writer, file)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadSegments downloads every mpeg transport stream segment to a given
|
|
||||||
// directory (more information below).
|
|
||||||
// After every segment download onSegmentDownload will be called with:
|
|
||||||
// the downloaded segment, the current position, the total size of segments to download,
|
|
||||||
// the file where the segment content was written to an error (if occurred).
|
|
||||||
// The filename is always <number of downloaded segment>.ts.
|
|
||||||
//
|
|
||||||
// Short explanation:
|
|
||||||
// The actual crunchyroll video is split up in multiple segments (or video files) which
|
|
||||||
// have to be downloaded and merged after to generate a single video file.
|
|
||||||
// And this function just downloads each of this segment into the given directory.
|
|
||||||
// See https://en.wikipedia.org/wiki/MPEG_transport_stream for more information.
|
|
||||||
func (d Downloader) downloadSegments(format *Format) ([]string, error) {
|
|
||||||
if err := format.InitVideo(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var lock sync.Mutex
|
|
||||||
chunkSize := int(math.Ceil(float64(format.Video.Chunklist.Count()) / float64(d.Goroutines)))
|
|
||||||
|
|
||||||
// when a onSegmentDownload call returns an error, this context will be set cancelled and stop all goroutines
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// receives the decrypt block and iv from the first segment.
|
|
||||||
// in my tests, only the first segment has specified this data, so the decryption data from this first segments will be used in every other segment too
|
|
||||||
block, iv, err := getCrypt(format, format.Video.Chunklist.Segments[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int32
|
|
||||||
for i := 0; i < int(format.Video.Chunklist.Count()); i += chunkSize {
|
|
||||||
wg.Add(1)
|
|
||||||
end := i + chunkSize
|
|
||||||
if end > int(format.Video.Chunklist.Count()) {
|
|
||||||
end = int(format.Video.Chunklist.Count())
|
|
||||||
}
|
|
||||||
i := i
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
for j, segment := range format.Video.Chunklist.Segments[i:end] {
|
|
||||||
select {
|
|
||||||
case <-d.Context.Done():
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
var file *os.File
|
|
||||||
for k := 0; k < 3; k++ {
|
|
||||||
filename := filepath.Join(d.TempDir, fmt.Sprintf("%d.ts", i+j))
|
|
||||||
file, err = d.downloadSegment(format, segment, filename, block, iv)
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if k == 2 {
|
|
||||||
file.Close()
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-d.Context.Done():
|
|
||||||
case <-ctx.Done():
|
|
||||||
file.Close()
|
|
||||||
return
|
|
||||||
case <-time.After(5 * time.Duration(k) * time.Second):
|
|
||||||
// sleep if an error occurs. very useful because sometimes the connection times out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.OnSegmentDownload != nil {
|
|
||||||
if d.LockOnSegmentDownload {
|
|
||||||
lock.Lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = d.OnSegmentDownload(segment, int(atomic.AddInt32(&total, 1)), int(format.Video.Chunklist.Count()), file); err != nil {
|
|
||||||
if d.LockOnSegmentDownload {
|
|
||||||
lock.Unlock()
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if d.LockOnSegmentDownload {
|
|
||||||
lock.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-d.Context.Done():
|
|
||||||
return nil, d.Context.Err()
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, err
|
|
||||||
default:
|
|
||||||
var files []string
|
|
||||||
for i := 0; i < int(total); i++ {
|
|
||||||
files = append(files, filepath.Join(d.TempDir, fmt.Sprintf("%d.ts", i)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCrypt extracts the key and iv of a m3u8 segment and converts it into a cipher.Block and an iv byte sequence.
|
|
||||||
func getCrypt(format *Format, segment *m3u8.MediaSegment) (block cipher.Block, iv []byte, err error) {
|
|
||||||
var resp *http.Response
|
|
||||||
|
|
||||||
resp, err = format.crunchy.Client.Get(segment.Key.URI)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
key, err := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
block, err = aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
iv = []byte(segment.Key.IV)
|
|
||||||
if len(iv) == 0 {
|
|
||||||
iv = key
|
|
||||||
}
|
|
||||||
|
|
||||||
return block, iv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadSegment downloads a segment, decrypts it and names it after the given index.
|
|
||||||
func (d Downloader) downloadSegment(format *Format, segment *m3u8.MediaSegment, filename string, block cipher.Block, iv []byte) (*os.File, error) {
|
|
||||||
// every segment is aes-128 encrypted and has to be decrypted when downloaded
|
|
||||||
content, err := d.decryptSegment(format.crunchy.Client, segment, block, iv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
if _, err = file.Write(content); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L25.
|
|
||||||
func (d Downloader) decryptSegment(client *http.Client, segment *m3u8.MediaSegment, block cipher.Block, iv []byte) ([]byte, error) {
|
|
||||||
req, err := http.NewRequestWithContext(d.Context, http.MethodGet, segment.URI, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
blockMode := cipher.NewCBCDecrypter(block, iv[:block.BlockSize()])
|
|
||||||
decrypted := make([]byte, len(raw))
|
|
||||||
blockMode.CryptBlocks(decrypted, raw)
|
|
||||||
raw = d.pkcs5UnPadding(decrypted)
|
|
||||||
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L47.
|
|
||||||
func (d Downloader) pkcs5UnPadding(origData []byte) []byte {
|
|
||||||
length := len(origData)
|
|
||||||
unPadding := int(origData[length-1])
|
|
||||||
return origData[:(length - unPadding)]
|
|
||||||
}
|
|
||||||
342
episode.go
342
episode.go
|
|
@ -1,342 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Episode contains all information about an episode.
|
|
||||||
type Episode struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
children []*Stream
|
|
||||||
|
|
||||||
ID string `json:"id"`
|
|
||||||
ChannelID string `json:"channel_id"`
|
|
||||||
|
|
||||||
SeriesID string `json:"series_id"`
|
|
||||||
SeriesTitle string `json:"series_title"`
|
|
||||||
SeriesSlugTitle string `json:"series_slug_title"`
|
|
||||||
|
|
||||||
SeasonID string `json:"season_id"`
|
|
||||||
SeasonTitle string `json:"season_title"`
|
|
||||||
SeasonSlugTitle string `json:"season_slug_title"`
|
|
||||||
SeasonNumber int `json:"season_number"`
|
|
||||||
|
|
||||||
Episode string `json:"episode"`
|
|
||||||
EpisodeNumber int `json:"episode_number"`
|
|
||||||
SequenceNumber float64 `json:"sequence_number"`
|
|
||||||
ProductionEpisodeID string `json:"production_episode_id"`
|
|
||||||
|
|
||||||
Title string `json:"title"`
|
|
||||||
SlugTitle string `json:"slug_title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
NextEpisodeID string `json:"next_episode_id"`
|
|
||||||
NextEpisodeTitle string `json:"next_episode_title"`
|
|
||||||
|
|
||||||
HDFlag bool `json:"hd_flag"`
|
|
||||||
MaturityRatings []string `json:"maturity_ratings"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
MatureBlocked bool `json:"mature_blocked"`
|
|
||||||
|
|
||||||
EpisodeAirDate time.Time `json:"episode_air_date"`
|
|
||||||
FreeAvailableDate time.Time `json:"free_available_date"`
|
|
||||||
PremiumAvailableDate time.Time `json:"premium_available_date"`
|
|
||||||
|
|
||||||
IsSubbed bool `json:"is_subbed"`
|
|
||||||
IsDubbed bool `json:"is_dubbed"`
|
|
||||||
IsClip bool `json:"is_clip"`
|
|
||||||
SeoTitle string `json:"seo_title"`
|
|
||||||
SeoDescription string `json:"seo_description"`
|
|
||||||
SeasonTags []string `json:"season_tags"`
|
|
||||||
|
|
||||||
AvailableOffline bool `json:"available_offline"`
|
|
||||||
MediaType MediaType `json:"media_type"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
|
|
||||||
Images struct {
|
|
||||||
Thumbnail [][]Image `json:"thumbnail"`
|
|
||||||
} `json:"images"`
|
|
||||||
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
IsPremiumOnly bool `json:"is_premium_only"`
|
|
||||||
ListingID string `json:"listing_id"`
|
|
||||||
|
|
||||||
SubtitleLocales []LOCALE `json:"subtitle_locales"`
|
|
||||||
Playback string `json:"playback"`
|
|
||||||
|
|
||||||
AvailabilityNotes string `json:"availability_notes"`
|
|
||||||
|
|
||||||
StreamID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// HistoryEpisode contains additional information about an episode if the account has watched or started to watch the episode.
|
|
||||||
type HistoryEpisode struct {
|
|
||||||
*Episode
|
|
||||||
|
|
||||||
DatePlayed time.Time `json:"date_played"`
|
|
||||||
ParentID string `json:"parent_id"`
|
|
||||||
ParentType MediaType `json:"parent_type"`
|
|
||||||
Playhead uint `json:"playhead"`
|
|
||||||
FullyWatched bool `json:"fully_watched"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WatchlistEntryType specifies which type a watchlist entry has.
|
|
||||||
type WatchlistEntryType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
WatchlistEntryEpisode = "episode"
|
|
||||||
WatchlistEntrySeries = "series"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EpisodeFromID returns an episode by its api id.
|
|
||||||
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
|
|
||||||
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
episode := &Episode{
|
|
||||||
crunchy: crunchy,
|
|
||||||
ID: id,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(jsonBody, episode); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if episode.Playback != "" {
|
|
||||||
streamHref := jsonBody["__links__"].(map[string]interface{})["streams"].(map[string]interface{})["href"].(string)
|
|
||||||
if match := regexp.MustCompile(`(?m)^/cms/v2/\S+videos/(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
|
|
||||||
episode.StreamID = match[0][1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return episode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddToWatchlist adds the current episode to the watchlist.
|
|
||||||
// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before.
|
|
||||||
// There is currently a bug, or as I like to say in context of the crunchyroll api, feature, that only series and not
|
|
||||||
// individual episode can be added to the watchlist. Even though I somehow got an episode to my watchlist on the
|
|
||||||
// crunchyroll website, it never worked with the api here. So this function actually adds the whole series to the watchlist.
|
|
||||||
func (e *Episode) AddToWatchlist() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", e.crunchy.Config.AccountID, e.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"content_id": e.SeriesID})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = e.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFromWatchlist removes the current episode from the watchlist.
|
|
||||||
// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before.
|
|
||||||
func (e *Episode) RemoveFromWatchlist() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", e.crunchy.Config.AccountID, e.SeriesID, e.crunchy.Locale)
|
|
||||||
_, err := e.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// AudioLocale returns the audio locale of the episode.
|
|
||||||
// Every episode in a season (should) have the same audio locale,
|
|
||||||
// so if you want to get the audio locale of a season, just call
|
|
||||||
// this method on the first episode of the season.
|
|
||||||
// Will fail if no streams are available, thus use Episode.Available
|
|
||||||
// to prevent any misleading errors.
|
|
||||||
func (e *Episode) AudioLocale() (LOCALE, error) {
|
|
||||||
streams, err := e.Streams()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return streams[0].AudioLocale, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comment creates a new comment under the episode.
|
|
||||||
func (e *Episode) Comment(message string, spoiler bool) (*Comment, error) {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?locale=%s", e.ID, e.crunchy.Locale)
|
|
||||||
var flags []string
|
|
||||||
if spoiler {
|
|
||||||
flags = append(flags, "spoiler")
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(map[string]any{"locale": string(e.crunchy.Locale), "flags": flags, "message": message})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
resp, err := e.crunchy.requestFull(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
c := &Comment{
|
|
||||||
crunchy: e.crunchy,
|
|
||||||
EpisodeID: e.ID,
|
|
||||||
}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(c); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommentsOrderType represents a sort type to sort Episode.Comments after.
|
|
||||||
type CommentsOrderType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CommentsOrderAsc CommentsOrderType = "asc"
|
|
||||||
CommentsOrderDesc = "desc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CommentsSortType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CommentsSortPopular CommentsSortType = "popular"
|
|
||||||
CommentsSortDate = "date"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CommentsOptions struct {
|
|
||||||
// Order specified the order how the comments should be returned.
|
|
||||||
Order CommentsOrderType `json:"order"`
|
|
||||||
|
|
||||||
// Sort specified after which key the comments should be sorted.
|
|
||||||
Sort CommentsSortType `json:"sort"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comments returns comments under the given episode.
|
|
||||||
func (e *Episode) Comments(options CommentsOptions, page uint, size uint) (c []*Comment, err error) {
|
|
||||||
options, err = structDefaults(CommentsOptions{Order: CommentsOrderDesc, Sort: CommentsSortPopular}, options)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/talkbox/guestbooks/%s/comments?page=%d&page_size=%d&order=%s&sort=%s&locale=%s", e.ID, page, size, options.Order, options.Sort, e.crunchy.Locale)
|
|
||||||
resp, err := e.crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]any
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
if err = decodeMapToStruct(jsonBody["items"].([]any), &c); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, comment := range c {
|
|
||||||
comment.crunchy = e.crunchy
|
|
||||||
comment.EpisodeID = e.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available returns if downloadable streams for this episodes are available.
|
|
||||||
func (e *Episode) Available() bool {
|
|
||||||
return e.crunchy.Config.Premium || !e.IsPremiumOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFormat returns the format which matches the given resolution and subtitle locale.
|
|
||||||
func (e *Episode) GetFormat(resolution string, subtitle LOCALE, hardsub bool) (*Format, error) {
|
|
||||||
streams, err := e.Streams()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var foundStream *Stream
|
|
||||||
for _, stream := range streams {
|
|
||||||
if hardsub && stream.HardsubLocale == subtitle || stream.HardsubLocale == "" && subtitle == "" {
|
|
||||||
foundStream = stream
|
|
||||||
break
|
|
||||||
} else if !hardsub {
|
|
||||||
for _, streamSubtitle := range stream.Subtitles {
|
|
||||||
if streamSubtitle.Locale == subtitle {
|
|
||||||
foundStream = stream
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if foundStream != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundStream == nil {
|
|
||||||
return nil, fmt.Errorf("no matching stream found")
|
|
||||||
}
|
|
||||||
formats, err := foundStream.Formats()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var res *Format
|
|
||||||
for _, format := range formats {
|
|
||||||
if resolution == "worst" || resolution == "best" {
|
|
||||||
if res == nil {
|
|
||||||
res = format
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
curSplitRes := strings.SplitN(format.Video.Resolution, "x", 2)
|
|
||||||
curResX, _ := strconv.Atoi(curSplitRes[0])
|
|
||||||
curResY, _ := strconv.Atoi(curSplitRes[1])
|
|
||||||
|
|
||||||
resSplitRes := strings.SplitN(res.Video.Resolution, "x", 2)
|
|
||||||
resResX, _ := strconv.Atoi(resSplitRes[0])
|
|
||||||
resResY, _ := strconv.Atoi(resSplitRes[1])
|
|
||||||
|
|
||||||
if resolution == "worst" && curResX+curResY < resResX+resResY {
|
|
||||||
res = format
|
|
||||||
} else if resolution == "best" && curResX+curResY > resResX+resResY {
|
|
||||||
res = format
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if format.Video.Resolution == resolution {
|
|
||||||
return format, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if res != nil {
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no matching resolution found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streams returns all streams which are available for the episode.
|
|
||||||
func (e *Episode) Streams() ([]*Stream, error) {
|
|
||||||
if e.children != nil {
|
|
||||||
return e.children, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
streams, err := fromVideoStreams(e.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
e.crunchy.Config.Bucket,
|
|
||||||
e.StreamID,
|
|
||||||
e.crunchy.Locale,
|
|
||||||
e.crunchy.Config.Signature,
|
|
||||||
e.crunchy.Config.Policy,
|
|
||||||
e.crunchy.Config.KeyPairID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.crunchy.cache {
|
|
||||||
e.children = streams
|
|
||||||
}
|
|
||||||
return streams, nil
|
|
||||||
}
|
|
||||||
17
error.go
17
error.go
|
|
@ -1,17 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RequestError struct {
|
|
||||||
error
|
|
||||||
|
|
||||||
Response *http.Response
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (re *RequestError) Error() string {
|
|
||||||
return fmt.Sprintf("error for endpoint %s (%d): %s", re.Response.Request.URL.String(), re.Response.StatusCode, re.Message)
|
|
||||||
}
|
|
||||||
52
format.go
52
format.go
|
|
@ -1,52 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/grafov/m3u8"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FormatType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EPISODE FormatType = "episodes"
|
|
||||||
MOVIE = "movies"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Format contains detailed information about an episode video stream.
|
|
||||||
type Format struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
ID string
|
|
||||||
// FormatType represents if the format parent is an episode or a movie.
|
|
||||||
FormatType FormatType
|
|
||||||
Video *m3u8.Variant
|
|
||||||
AudioLocale LOCALE
|
|
||||||
Hardsub LOCALE
|
|
||||||
Subtitles []*Subtitle
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitVideo initializes the Format.Video completely.
|
|
||||||
// The Format.Video.Chunklist pointer is, by default, nil because an additional
|
|
||||||
// request must be made to receive its content. The request is not made when
|
|
||||||
// initializing a Format struct because it would probably cause an intense overhead
|
|
||||||
// since Format.Video.Chunklist is only used sometimes.
|
|
||||||
func (f *Format) InitVideo() error {
|
|
||||||
if f.Video.Chunklist == nil {
|
|
||||||
resp, err := f.crunchy.Client.Get(f.Video.URI)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f.Video.Chunklist = playlist.(*m3u8.MediaPlaylist)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download downloads the Format with the via Downloader specified options.
|
|
||||||
func (f *Format) Download(downloader Downloader) error {
|
|
||||||
return downloader.download(f)
|
|
||||||
}
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -1,8 +1,9 @@
|
||||||
module github.com/ByteDream/crunchyroll-go/v3
|
module github.com/ByteDream/crunchy-cli
|
||||||
|
|
||||||
go 1.18
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c
|
||||||
github.com/grafov/m3u8 v0.11.1
|
github.com/grafov/m3u8 v0.11.1
|
||||||
github.com/spf13/cobra v1.4.0
|
github.com/spf13/cobra v1.4.0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY=
|
||||||
|
github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
|
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
|
||||||
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
|
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
|
||||||
|
|
|
||||||
7
main.go
Normal file
7
main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/ByteDream/crunchy-cli/commands"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
commands.Execute()
|
||||||
|
}
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MovieListing contains information about something which is called
|
|
||||||
// movie listing. I don't know what this means thb.
|
|
||||||
type MovieListing struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
ID string `json:"id"`
|
|
||||||
|
|
||||||
Title string `json:"title"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
SlugTitle string `json:"slug_title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
|
|
||||||
Images struct {
|
|
||||||
Thumbnail [][]Image `json:"thumbnail"`
|
|
||||||
} `json:"images"`
|
|
||||||
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
IsPremiumOnly bool `json:"is_premium_only"`
|
|
||||||
ListeningID string `json:"listening_id"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
AvailableOffline bool `json:"available_offline"`
|
|
||||||
IsSubbed bool `json:"is_subbed"`
|
|
||||||
IsDubbed bool `json:"is_dubbed"`
|
|
||||||
|
|
||||||
Playback string `json:"playback"`
|
|
||||||
AvailabilityNotes string `json:"availability_notes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieListingFromID returns a movie listing by its api id.
|
|
||||||
func MovieListingFromID(crunchy *Crunchyroll, id string) (*MovieListing, error) {
|
|
||||||
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movie_listing/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
movieListing := &MovieListing{
|
|
||||||
crunchy: crunchy,
|
|
||||||
ID: id,
|
|
||||||
}
|
|
||||||
if err = decodeMapToStruct(jsonBody, movieListing); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return movieListing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AudioLocale is same as Episode.AudioLocale.
|
|
||||||
func (ml *MovieListing) AudioLocale() (LOCALE, error) {
|
|
||||||
resp, err := ml.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
ml.crunchy.Config.Bucket,
|
|
||||||
ml.ID,
|
|
||||||
ml.crunchy.Locale,
|
|
||||||
ml.crunchy.Config.Signature,
|
|
||||||
ml.crunchy.Config.Policy,
|
|
||||||
ml.crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
return LOCALE(jsonBody["audio_locale"].(string)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streams returns all streams which are available for the movie listing.
|
|
||||||
func (ml *MovieListing) Streams() ([]*Stream, error) {
|
|
||||||
return fromVideoStreams(ml.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
ml.crunchy.Config.Bucket,
|
|
||||||
ml.ID,
|
|
||||||
ml.crunchy.Locale,
|
|
||||||
ml.crunchy.Config.Signature,
|
|
||||||
ml.crunchy.Config.Policy,
|
|
||||||
ml.crunchy.Config.KeyPairID))
|
|
||||||
}
|
|
||||||
55
news.go
55
news.go
|
|
@ -1,55 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// News returns the top and latest news from crunchyroll for the current locale within the given limits.
|
|
||||||
func (c *Crunchyroll) News(topLimit uint, latestLimit uint) (t []*News, l []*News, err error) {
|
|
||||||
newsFeedEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/news_feed?top_news_n=%d&latest_news_n=%d&locale=%s",
|
|
||||||
topLimit, latestLimit, c.Locale)
|
|
||||||
resp, err := c.request(newsFeedEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to parse 'news_feed' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
topNews := jsonBody["top_news"].(map[string]interface{})
|
|
||||||
for _, item := range topNews["items"].([]interface{}) {
|
|
||||||
topNews := &News{}
|
|
||||||
if err := decodeMapToStruct(item, topNews); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t = append(t, topNews)
|
|
||||||
}
|
|
||||||
|
|
||||||
latestNews := jsonBody["latest_news"].(map[string]interface{})
|
|
||||||
for _, item := range latestNews["items"].([]interface{}) {
|
|
||||||
latestNews := &News{}
|
|
||||||
if err := decodeMapToStruct(item, latestNews); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
l = append(l, latestNews)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, l, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// News contains all information about news.
|
|
||||||
type News struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Creator string `json:"creator"`
|
|
||||||
PublishDate string `json:"publish_date"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
69
parse.go
69
parse.go
|
|
@ -1,69 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseBetaSeriesURL tries to extract the season id of the given crunchyroll beta url, pointing to a season.
|
|
||||||
func ParseBetaSeriesURL(url string) (seasonId string, ok bool) {
|
|
||||||
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?series/(?P<seasonId>\w+).*`)
|
|
||||||
if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
|
|
||||||
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
|
|
||||||
seasonId = groups["seasonId"]
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseBetaEpisodeURL tries to extract the episode id of the given crunchyroll beta url, pointing to an episode.
|
|
||||||
func ParseBetaEpisodeURL(url string) (episodeId string, ok bool) {
|
|
||||||
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?beta\.crunchyroll\.com/(\w{2}/)?watch/(?P<episodeId>\w+).*`)
|
|
||||||
if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
|
|
||||||
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
|
|
||||||
episodeId = groups["episodeId"]
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseVideoURL tries to extract the crunchyroll series / movie name out of the given url.
|
|
||||||
//
|
|
||||||
// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaSeriesURL
|
|
||||||
// if possible since beta url are always safe to use.
|
|
||||||
// The method will stay in the library until only beta urls are supported by crunchyroll itself.
|
|
||||||
func ParseVideoURL(url string) (seriesName string, ok bool) {
|
|
||||||
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)(/videos)?/?$`)
|
|
||||||
if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
|
|
||||||
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
|
|
||||||
seriesName = groups["series"]
|
|
||||||
|
|
||||||
if seriesName != "" {
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseEpisodeURL tries to extract the crunchyroll series name, title, episode number and web id out of the given crunchyroll url
|
|
||||||
// Note that the episode number can be misleading. For example if an episode has the episode number 23.5 (slime isekai)
|
|
||||||
// the episode number will be 235.
|
|
||||||
//
|
|
||||||
// Deprecated: Crunchyroll classic urls are sometimes not safe to use, use ParseBetaEpisodeURL
|
|
||||||
// if possible since beta url are always safe to use.
|
|
||||||
// The method will stay in the library until only beta urls are supported by crunchyroll itself.
|
|
||||||
func ParseEpisodeURL(url string) (seriesName, title string, episodeNumber int, webId int, ok bool) {
|
|
||||||
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)/episode-(?P<number>\d+)-(?P<title>.+)-(?P<webId>\d+).*`)
|
|
||||||
if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
|
|
||||||
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
|
|
||||||
seriesName = groups["series"]
|
|
||||||
episodeNumber, _ = strconv.Atoi(groups["number"])
|
|
||||||
title = groups["title"]
|
|
||||||
webId, _ = strconv.Atoi(groups["webId"])
|
|
||||||
|
|
||||||
if seriesName != "" && title != "" && webId != 0 {
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
193
search.go
193
search.go
|
|
@ -1,193 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BrowseSortType represents a sort type to sort Crunchyroll.Browse items after.
|
|
||||||
type BrowseSortType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
BrowseSortPopularity BrowseSortType = "popularity"
|
|
||||||
BrowseSortNewlyAdded = "newly_added"
|
|
||||||
BrowseSortAlphabetical = "alphabetical"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BrowseOptions represents options for browsing the crunchyroll catalog.
|
|
||||||
type BrowseOptions struct {
|
|
||||||
// Categories specifies the categories of the entries.
|
|
||||||
Categories []string `param:"categories"`
|
|
||||||
|
|
||||||
// IsDubbed specifies whether the entries should be dubbed.
|
|
||||||
IsDubbed bool `param:"is_dubbed"`
|
|
||||||
|
|
||||||
// IsSubbed specifies whether the entries should be subbed.
|
|
||||||
IsSubbed bool `param:"is_subbed"`
|
|
||||||
|
|
||||||
// Simulcast specifies a particular simulcast season by id in which the entries have been aired.
|
|
||||||
Simulcast string `param:"season_tag"`
|
|
||||||
|
|
||||||
// Sort specifies how the entries should be sorted.
|
|
||||||
Sort BrowseSortType `param:"sort_by"`
|
|
||||||
|
|
||||||
// Start specifies the index from which the entries should be returned.
|
|
||||||
Start uint `param:"start"`
|
|
||||||
|
|
||||||
// Type specifies the media type of the entries.
|
|
||||||
Type MediaType `param:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse browses the crunchyroll catalog filtered by the specified options and returns all found series and movies within the given limit.
|
|
||||||
func (c *Crunchyroll) Browse(options BrowseOptions, limit uint) (s []*Series, m []*Movie, err error) {
|
|
||||||
query, err := encodeStructToQueryValues(options)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
browseEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/browse?%s&n=%d&locale=%s",
|
|
||||||
query, limit, c.Locale)
|
|
||||||
resp, err := c.request(browseEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to parse 'browse' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
switch item.(map[string]interface{})["type"] {
|
|
||||||
case MediaTypeSeries:
|
|
||||||
series := &Series{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s = append(s, series)
|
|
||||||
case MediaTypeMovie:
|
|
||||||
movie := &Movie{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, movie); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m = append(m, movie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindVideoByName finds a Video (Season or Movie) by its name.
|
|
||||||
// Use this in combination with ParseVideoURL and hand over the corresponding results
|
|
||||||
// to this function.
|
|
||||||
//
|
|
||||||
// Deprecated: Use Search instead. The first result sometimes isn't the correct one
|
|
||||||
// so this function is inaccurate in some cases.
|
|
||||||
// See https://github.com/ByteDream/crunchyroll-go/issues/22 for more information.
|
|
||||||
func (c *Crunchyroll) FindVideoByName(seriesName string) (Video, error) {
|
|
||||||
s, m, err := c.Search(seriesName, 1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(s) > 0 {
|
|
||||||
return s[0], nil
|
|
||||||
} else if len(m) > 0 {
|
|
||||||
return m[0], nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("no series or movie could be found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindEpisodeByName finds an episode by its crunchyroll series name and episode title.
|
|
||||||
// Use this in combination with ParseEpisodeURL and hand over the corresponding results
|
|
||||||
// to this function.
|
|
||||||
func (c *Crunchyroll) FindEpisodeByName(seriesName, episodeTitle string) ([]*Episode, error) {
|
|
||||||
series, _, err := c.Search(seriesName, 5)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var matchingEpisodes []*Episode
|
|
||||||
for _, s := range series {
|
|
||||||
seasons, err := s.Seasons()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, season := range seasons {
|
|
||||||
episodes, err := season.Episodes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, episode := range episodes {
|
|
||||||
if episode.SlugTitle == episodeTitle {
|
|
||||||
matchingEpisodes = append(matchingEpisodes, episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchingEpisodes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search searches a query and returns all found series and movies within the given limit.
|
|
||||||
func (c *Crunchyroll) Search(query string, limit uint) (s []*Series, m []*Movie, err error) {
|
|
||||||
searchEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/search?q=%s&n=%d&type=&locale=%s",
|
|
||||||
query, limit, c.Locale)
|
|
||||||
resp, err := c.request(searchEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to parse 'search' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
item := item.(map[string]interface{})
|
|
||||||
if item["total"].(float64) > 0 {
|
|
||||||
switch item["type"] {
|
|
||||||
case MediaTypeSeries:
|
|
||||||
for _, series := range item["items"].([]interface{}) {
|
|
||||||
series2 := &Series{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(series, series2); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s = append(s, series2)
|
|
||||||
}
|
|
||||||
case MediaTypeMovie:
|
|
||||||
for _, movie := range item["items"].([]interface{}) {
|
|
||||||
movie2 := &Movie{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(movie, movie2); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m = append(m, movie2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, m, nil
|
|
||||||
}
|
|
||||||
135
season.go
135
season.go
|
|
@ -1,135 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Season contains information about an anime season.
|
|
||||||
type Season struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
children []*Episode
|
|
||||||
|
|
||||||
ID string `json:"id"`
|
|
||||||
ChannelID string `json:"channel_id"`
|
|
||||||
|
|
||||||
Title string `json:"title"`
|
|
||||||
SlugTitle string `json:"slug_title"`
|
|
||||||
|
|
||||||
SeriesID string `json:"series_id"`
|
|
||||||
SeasonNumber int `json:"season_number"`
|
|
||||||
|
|
||||||
IsComplete bool `json:"is_complete"`
|
|
||||||
|
|
||||||
Description string `json:"description"`
|
|
||||||
Keywords []string `json:"keywords"`
|
|
||||||
SeasonTags []string `json:"season_tags"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
MatureBlocked bool `json:"mature_blocked"`
|
|
||||||
IsSubbed bool `json:"is_subbed"`
|
|
||||||
IsDubbed bool `json:"is_dubbed"`
|
|
||||||
IsSimulcast bool `json:"is_simulcast"`
|
|
||||||
|
|
||||||
SeoTitle string `json:"seo_title"`
|
|
||||||
SeoDescription string `json:"seo_description"`
|
|
||||||
|
|
||||||
AvailabilityNotes string `json:"availability_notes"`
|
|
||||||
|
|
||||||
// the locales are always empty, idk why, this may change in the future
|
|
||||||
AudioLocales []LOCALE
|
|
||||||
SubtitleLocales []LOCALE
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeasonFromID returns a season by its api id.
|
|
||||||
func SeasonFromID(crunchy *Crunchyroll, id string) (*Season, error) {
|
|
||||||
resp, err := crunchy.Client.Get(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
season := &Season{
|
|
||||||
crunchy: crunchy,
|
|
||||||
ID: id,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(jsonBody, season); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return season, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AudioLocale returns the audio locale of the season.
|
|
||||||
// Will fail if no streams are available, thus use Season.Available
|
|
||||||
// to prevent any misleading errors.
|
|
||||||
func (s *Season) AudioLocale() (LOCALE, error) {
|
|
||||||
episodes, err := s.Episodes()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return episodes[0].AudioLocale()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available returns if downloadable streams for this season are available.
|
|
||||||
func (s *Season) Available() (bool, error) {
|
|
||||||
episodes, err := s.Episodes()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return episodes[0].Available(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Episodes returns all episodes which are available for the season.
|
|
||||||
func (s *Season) Episodes() (episodes []*Episode, err error) {
|
|
||||||
if s.children != nil {
|
|
||||||
return s.children, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/episodes?season_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
s.crunchy.Config.Bucket,
|
|
||||||
s.ID,
|
|
||||||
s.crunchy.Locale,
|
|
||||||
s.crunchy.Config.Signature,
|
|
||||||
s.crunchy.Config.Policy,
|
|
||||||
s.crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
episode := &Episode{
|
|
||||||
crunchy: s.crunchy,
|
|
||||||
}
|
|
||||||
if err = decodeMapToStruct(item, episode); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if episode.Playback != "" {
|
|
||||||
streamHref := item.(map[string]interface{})["__links__"].(map[string]interface{})["streams"].(map[string]interface{})["href"].(string)
|
|
||||||
if match := regexp.MustCompile(`(?m)(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
|
|
||||||
episode.StreamID = match[0][1]
|
|
||||||
} else {
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
episodes = append(episodes, episode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.crunchy.cache {
|
|
||||||
s.children = episodes
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
45
simulcast.go
45
simulcast.go
|
|
@ -1,45 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simulcasts returns all available simulcast seasons for the current locale.
|
|
||||||
func (c *Crunchyroll) Simulcasts() (s []*Simulcast, err error) {
|
|
||||||
seasonListEndpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/season_list?locale=%s", c.Locale)
|
|
||||||
resp, err := c.request(seasonListEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'season_list' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
simulcast := &Simulcast{}
|
|
||||||
if err := decodeMapToStruct(item, simulcast); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s = append(s, simulcast)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulcast contains all information about a simulcast season.
|
|
||||||
type Simulcast struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
|
|
||||||
Localization struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
|
|
||||||
// appears to be always an empty string.
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"localization"`
|
|
||||||
}
|
|
||||||
129
stream.go
129
stream.go
|
|
@ -1,129 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/grafov/m3u8"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stream contains information about all available video stream of an episode.
|
|
||||||
type Stream struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
children []*Format
|
|
||||||
|
|
||||||
HardsubLocale LOCALE
|
|
||||||
AudioLocale LOCALE
|
|
||||||
Subtitles []*Subtitle
|
|
||||||
|
|
||||||
formatType FormatType
|
|
||||||
id string
|
|
||||||
streamURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamsFromID returns a stream by its api id.
|
|
||||||
func StreamsFromID(crunchy *Crunchyroll, id string) ([]*Stream, error) {
|
|
||||||
return fromVideoStreams(crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formats returns all formats which are available for the stream.
|
|
||||||
func (s *Stream) Formats() ([]*Format, error) {
|
|
||||||
if s.children != nil {
|
|
||||||
return s.children, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.crunchy.Client.Get(s.streamURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var formats []*Format
|
|
||||||
for _, variant := range playlist.(*m3u8.MasterPlaylist).Variants {
|
|
||||||
formats = append(formats, &Format{
|
|
||||||
crunchy: s.crunchy,
|
|
||||||
ID: s.id,
|
|
||||||
FormatType: s.formatType,
|
|
||||||
Video: variant,
|
|
||||||
AudioLocale: s.AudioLocale,
|
|
||||||
Hardsub: s.HardsubLocale,
|
|
||||||
Subtitles: s.Subtitles,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.crunchy.cache {
|
|
||||||
s.children = formats
|
|
||||||
}
|
|
||||||
return formats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fromVideoStreams returns all streams which are accessible via the endpoint.
|
|
||||||
func fromVideoStreams(crunchy *Crunchyroll, endpoint string) (streams []*Stream, err error) {
|
|
||||||
resp, err := crunchy.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
if len(jsonBody) == 0 {
|
|
||||||
// this may get thrown when the crunchyroll account is just a normal account and not one with premium
|
|
||||||
if !crunchy.Config.Premium {
|
|
||||||
return nil, fmt.Errorf("no stream available, this might be the result of using a non-premium account")
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("no stream available")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioLocale := jsonBody["audio_locale"].(string)
|
|
||||||
|
|
||||||
var subtitles []*Subtitle
|
|
||||||
for _, rawSubtitle := range jsonBody["subtitles"].(map[string]interface{}) {
|
|
||||||
subtitle := &Subtitle{
|
|
||||||
crunchy: crunchy,
|
|
||||||
}
|
|
||||||
decodeMapToStruct(rawSubtitle.(map[string]interface{}), subtitle)
|
|
||||||
subtitles = append(subtitles, subtitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, streamData := range jsonBody["streams"].(map[string]interface{})["adaptive_hls"].(map[string]interface{}) {
|
|
||||||
streamData := streamData.(map[string]interface{})
|
|
||||||
|
|
||||||
hardsubLocale := streamData["hardsub_locale"].(string)
|
|
||||||
|
|
||||||
var id string
|
|
||||||
var formatType FormatType
|
|
||||||
href := jsonBody["__links__"].(map[string]interface{})["resource"].(map[string]interface{})["href"].(string)
|
|
||||||
if match := regexp.MustCompile(`(?sm)/(\w+)/(\w+)$`).FindAllStringSubmatch(href, -1); len(match) > 0 {
|
|
||||||
formatType = FormatType(match[0][1])
|
|
||||||
id = match[0][2]
|
|
||||||
}
|
|
||||||
|
|
||||||
stream := &Stream{
|
|
||||||
crunchy: crunchy,
|
|
||||||
HardsubLocale: LOCALE(hardsubLocale),
|
|
||||||
formatType: formatType,
|
|
||||||
id: id,
|
|
||||||
streamURL: streamData["url"].(string),
|
|
||||||
AudioLocale: LOCALE(audioLocale),
|
|
||||||
Subtitles: subtitles,
|
|
||||||
}
|
|
||||||
|
|
||||||
streams = append(streams, stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
32
subtitle.go
32
subtitle.go
|
|
@ -1,32 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Subtitle contains the information about a video subtitle.
|
|
||||||
type Subtitle struct {
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
Locale LOCALE `json:"locale"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Format string `json:"format"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save writes the subtitle to the given io.Writer.
|
|
||||||
func (s Subtitle) Save(writer io.Writer) error {
|
|
||||||
req, err := http.NewRequestWithContext(s.crunchy.Context, http.MethodGet, s.URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.crunchy.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(writer, resp.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Recommendations returns series and movie recommendations from crunchyroll based on the currently logged in account within the given limit.
|
|
||||||
func (c *Crunchyroll) Recommendations(limit uint) (s []*Series, m []*Movie, err error) {
|
|
||||||
recommendationsEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/recommendations?n=%d&locale=%s",
|
|
||||||
c.Config.AccountID, limit, c.Locale)
|
|
||||||
resp, err := c.request(recommendationsEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to parse 'recommendations' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
switch item.(map[string]interface{})["type"] {
|
|
||||||
case MediaTypeSeries:
|
|
||||||
series := &Series{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s = append(s, series)
|
|
||||||
case MediaTypeMovie:
|
|
||||||
movie := &Movie{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, movie); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m = append(m, movie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpNext returns the episodes that are up next based on the currently logged in account within the given limit.
|
|
||||||
func (c *Crunchyroll) UpNext(limit uint) (e []*Episode, err error) {
|
|
||||||
upNextAccountEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/up_next_account?n=%d&locale=%s",
|
|
||||||
c.Config.AccountID, limit, c.Locale)
|
|
||||||
resp, err := c.request(upNextAccountEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'up_next_account' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
panel := item.(map[string]interface{})["panel"]
|
|
||||||
|
|
||||||
episode := &Episode{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(panel, episode); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
e = append(e, episode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
128
url.go
128
url.go
|
|
@ -1,128 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExtractEpisodesFromUrl extracts all episodes from an url.
|
|
||||||
// If audio is not empty, the episodes gets filtered after the given locale.
|
|
||||||
func (c *Crunchyroll) ExtractEpisodesFromUrl(url string, audio ...LOCALE) ([]*Episode, error) {
|
|
||||||
series, episodes, err := c.ParseUrl(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var eps []*Episode
|
|
||||||
var notAvailableContinue bool
|
|
||||||
|
|
||||||
if series != nil {
|
|
||||||
seasons, err := series.Seasons()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, season := range seasons {
|
|
||||||
if audio != nil {
|
|
||||||
if available, err := season.Available(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if !available {
|
|
||||||
notAvailableContinue = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
locale, err := season.AudioLocale()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
for _, l := range audio {
|
|
||||||
if locale == l {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e, err := season.Episodes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
eps = append(eps, e...)
|
|
||||||
}
|
|
||||||
} else if episodes != nil {
|
|
||||||
if audio == nil {
|
|
||||||
return episodes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, episode := range episodes {
|
|
||||||
// if no episode streams are available, calling episode.AudioLocale
|
|
||||||
// will result in an unwanted error
|
|
||||||
if !episode.Available() {
|
|
||||||
notAvailableContinue = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
locale, err := episode.AudioLocale()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if audio != nil {
|
|
||||||
var found bool
|
|
||||||
for _, l := range audio {
|
|
||||||
if locale == l {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eps = append(eps, episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(eps) == 0 {
|
|
||||||
if notAvailableContinue {
|
|
||||||
return nil, fmt.Errorf("could not find any matching episode which is accessable with a non-premium account")
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("could not find any matching episode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return eps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseUrl parses the given url into a series or episode.
|
|
||||||
// The returning episode is a slice because non-beta urls have the same episode with different languages.
|
|
||||||
func (c *Crunchyroll) ParseUrl(url string) (*Series, []*Episode, error) {
|
|
||||||
if seriesId, ok := ParseBetaSeriesURL(url); ok {
|
|
||||||
series, err := SeriesFromID(c, seriesId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return series, nil, nil
|
|
||||||
} else if episodeId, ok := ParseBetaEpisodeURL(url); ok {
|
|
||||||
episode, err := EpisodeFromID(c, episodeId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return nil, []*Episode{episode}, nil
|
|
||||||
} else if seriesName, ok := ParseVideoURL(url); ok {
|
|
||||||
video, err := c.FindVideoByName(seriesName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return video.(*Series), nil, nil
|
|
||||||
} else if seriesName, title, _, _, ok := ParseEpisodeURL(url); ok {
|
|
||||||
episodes, err := c.FindEpisodeByName(seriesName, title)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return nil, episodes, nil
|
|
||||||
} else {
|
|
||||||
return nil, nil, fmt.Errorf("invalid url %s", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
84
utils.go
84
utils.go
|
|
@ -1,84 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func decodeMapToStruct(m interface{}, s interface{}) error {
|
|
||||||
jsonBody, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return json.Unmarshal(jsonBody, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func regexGroups(parsed [][]string, subexpNames ...string) map[string]string {
|
|
||||||
groups := map[string]string{}
|
|
||||||
for _, match := range parsed {
|
|
||||||
for i, content := range match {
|
|
||||||
if subexpName := subexpNames[i]; subexpName != "" {
|
|
||||||
groups[subexpName] = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeStructToQueryValues(s interface{}) (string, error) {
|
|
||||||
values := make(url.Values)
|
|
||||||
v := reflect.ValueOf(s)
|
|
||||||
|
|
||||||
for i := 0; i < v.Type().NumField(); i++ {
|
|
||||||
|
|
||||||
// don't include parameters with default or without values in the query to avoid corruption of the API response.
|
|
||||||
switch v.Field(i).Kind() {
|
|
||||||
case reflect.Slice, reflect.String:
|
|
||||||
if v.Field(i).Len() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case reflect.Bool:
|
|
||||||
if !v.Field(i).Bool() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case reflect.Uint:
|
|
||||||
if v.Field(i).Uint() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
key := v.Type().Field(i).Tag.Get("param")
|
|
||||||
var val string
|
|
||||||
|
|
||||||
if v.Field(i).Kind() == reflect.Slice {
|
|
||||||
var items []string
|
|
||||||
|
|
||||||
for _, i := range v.Field(i).Interface().([]string) {
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
val = strings.Join(items, ",")
|
|
||||||
} else {
|
|
||||||
val = fmt.Sprint(v.Field(i).Interface())
|
|
||||||
}
|
|
||||||
|
|
||||||
values.Add(key, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
return values.Encode(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func structDefaults[T any](defaultStruct T, customStruct T) (T, error) {
|
|
||||||
rawDefaultStruct, err := json.Marshal(defaultStruct)
|
|
||||||
if err != nil {
|
|
||||||
return *new(T), err
|
|
||||||
}
|
|
||||||
if err = json.NewDecoder(bytes.NewBuffer(rawDefaultStruct)).Decode(&customStruct); err != nil {
|
|
||||||
return *new(T), err
|
|
||||||
}
|
|
||||||
return customStruct, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/ByteDream/crunchyroll-go/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllLocales is an array of all available locales.
|
|
||||||
var AllLocales = []crunchyroll.LOCALE{
|
|
||||||
crunchyroll.JP,
|
|
||||||
crunchyroll.US,
|
|
||||||
crunchyroll.LA,
|
|
||||||
crunchyroll.ES,
|
|
||||||
crunchyroll.FR,
|
|
||||||
crunchyroll.PT,
|
|
||||||
crunchyroll.BR,
|
|
||||||
crunchyroll.IT,
|
|
||||||
crunchyroll.DE,
|
|
||||||
crunchyroll.RU,
|
|
||||||
crunchyroll.AR,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateLocale validates if the given locale actually exist.
|
|
||||||
func ValidateLocale(locale crunchyroll.LOCALE) bool {
|
|
||||||
for _, l := range AllLocales {
|
|
||||||
if l == locale {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocaleLanguage returns the country by its locale.
|
|
||||||
func LocaleLanguage(locale crunchyroll.LOCALE) string {
|
|
||||||
switch locale {
|
|
||||||
case crunchyroll.JP:
|
|
||||||
return "Japanese"
|
|
||||||
case crunchyroll.US:
|
|
||||||
return "English (US)"
|
|
||||||
case crunchyroll.LA:
|
|
||||||
return "Spanish (Latin America)"
|
|
||||||
case crunchyroll.ES:
|
|
||||||
return "Spanish (Spain)"
|
|
||||||
case crunchyroll.FR:
|
|
||||||
return "French"
|
|
||||||
case crunchyroll.PT:
|
|
||||||
return "Portuguese (Europe)"
|
|
||||||
case crunchyroll.BR:
|
|
||||||
return "Portuguese (Brazil)"
|
|
||||||
case crunchyroll.IT:
|
|
||||||
return "Italian"
|
|
||||||
case crunchyroll.DE:
|
|
||||||
return "German"
|
|
||||||
case crunchyroll.RU:
|
|
||||||
return "Russian"
|
|
||||||
case crunchyroll.AR:
|
|
||||||
return "Arabic"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
158
utils/sort.go
158
utils/sort.go
|
|
@ -1,158 +0,0 @@
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/ByteDream/crunchyroll-go/v3"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SortEpisodesBySeason sorts the given episodes by their seasons.
|
|
||||||
// Note that the same episodes just with different audio locales will cause problems.
|
|
||||||
func SortEpisodesBySeason(episodes []*crunchyroll.Episode) [][]*crunchyroll.Episode {
|
|
||||||
sortMap := map[string]map[int][]*crunchyroll.Episode{}
|
|
||||||
|
|
||||||
for _, episode := range episodes {
|
|
||||||
if _, ok := sortMap[episode.SeriesID]; !ok {
|
|
||||||
sortMap[episode.SeriesID] = map[int][]*crunchyroll.Episode{}
|
|
||||||
}
|
|
||||||
if _, ok := sortMap[episode.SeriesID][episode.SeasonNumber]; !ok {
|
|
||||||
sortMap[episode.SeriesID][episode.SeasonNumber] = make([]*crunchyroll.Episode, 0)
|
|
||||||
}
|
|
||||||
sortMap[episode.SeriesID][episode.SeasonNumber] = append(sortMap[episode.SeriesID][episode.SeasonNumber], episode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var eps [][]*crunchyroll.Episode
|
|
||||||
for _, series := range sortMap {
|
|
||||||
var keys []int
|
|
||||||
for seriesNumber := range series {
|
|
||||||
keys = append(keys, seriesNumber)
|
|
||||||
}
|
|
||||||
sort.Ints(keys)
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
es := series[key]
|
|
||||||
if len(es) > 0 {
|
|
||||||
sort.Sort(EpisodesByNumber(es))
|
|
||||||
eps = append(eps, es)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return eps
|
|
||||||
}
|
|
||||||
|
|
||||||
// SortEpisodesByAudio sort the given episodes by their audio locale.
|
|
||||||
func SortEpisodesByAudio(episodes []*crunchyroll.Episode) (map[crunchyroll.LOCALE][]*crunchyroll.Episode, error) {
|
|
||||||
eps := map[crunchyroll.LOCALE][]*crunchyroll.Episode{}
|
|
||||||
|
|
||||||
errChan := make(chan error)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var lock sync.Mutex
|
|
||||||
for _, episode := range episodes {
|
|
||||||
if !episode.Available() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
episode := episode
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
audioLocale, err := episode.AudioLocale()
|
|
||||||
if err != nil {
|
|
||||||
errChan <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lock.Lock()
|
|
||||||
defer lock.Unlock()
|
|
||||||
|
|
||||||
if _, ok := eps[audioLocale]; !ok {
|
|
||||||
eps[audioLocale] = make([]*crunchyroll.Episode, 0)
|
|
||||||
}
|
|
||||||
eps[audioLocale] = append(eps[audioLocale], episode)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
wg.Wait()
|
|
||||||
errChan <- nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := <-errChan; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return eps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieListingsByDuration sorts movie listings by their duration.
|
|
||||||
type MovieListingsByDuration []*crunchyroll.MovieListing
|
|
||||||
|
|
||||||
func (mlbd MovieListingsByDuration) Len() int {
|
|
||||||
return len(mlbd)
|
|
||||||
}
|
|
||||||
func (mlbd MovieListingsByDuration) Swap(i, j int) {
|
|
||||||
mlbd[i], mlbd[j] = mlbd[j], mlbd[i]
|
|
||||||
}
|
|
||||||
func (mlbd MovieListingsByDuration) Less(i, j int) bool {
|
|
||||||
return mlbd[i].DurationMS < mlbd[j].DurationMS
|
|
||||||
}
|
|
||||||
|
|
||||||
// EpisodesByDuration sorts episodes by their duration.
|
|
||||||
type EpisodesByDuration []*crunchyroll.Episode
|
|
||||||
|
|
||||||
func (ebd EpisodesByDuration) Len() int {
|
|
||||||
return len(ebd)
|
|
||||||
}
|
|
||||||
func (ebd EpisodesByDuration) Swap(i, j int) {
|
|
||||||
ebd[i], ebd[j] = ebd[j], ebd[i]
|
|
||||||
}
|
|
||||||
func (ebd EpisodesByDuration) Less(i, j int) bool {
|
|
||||||
return ebd[i].DurationMS < ebd[j].DurationMS
|
|
||||||
}
|
|
||||||
|
|
||||||
// EpisodesByNumber sorts episodes after their episode number.
|
|
||||||
type EpisodesByNumber []*crunchyroll.Episode
|
|
||||||
|
|
||||||
func (ebn EpisodesByNumber) Len() int {
|
|
||||||
return len(ebn)
|
|
||||||
}
|
|
||||||
func (ebn EpisodesByNumber) Swap(i, j int) {
|
|
||||||
ebn[i], ebn[j] = ebn[j], ebn[i]
|
|
||||||
}
|
|
||||||
func (ebn EpisodesByNumber) Less(i, j int) bool {
|
|
||||||
return ebn[i].EpisodeNumber < ebn[j].EpisodeNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatsByResolution sorts formats after their resolution.
|
|
||||||
type FormatsByResolution []*crunchyroll.Format
|
|
||||||
|
|
||||||
func (fbr FormatsByResolution) Len() int {
|
|
||||||
return len(fbr)
|
|
||||||
}
|
|
||||||
func (fbr FormatsByResolution) Swap(i, j int) {
|
|
||||||
fbr[i], fbr[j] = fbr[j], fbr[i]
|
|
||||||
}
|
|
||||||
func (fbr FormatsByResolution) Less(i, j int) bool {
|
|
||||||
iSplitRes := strings.SplitN(fbr[i].Video.Resolution, "x", 2)
|
|
||||||
iResX, _ := strconv.Atoi(iSplitRes[0])
|
|
||||||
iResY, _ := strconv.Atoi(iSplitRes[1])
|
|
||||||
|
|
||||||
jSplitRes := strings.SplitN(fbr[j].Video.Resolution, "x", 2)
|
|
||||||
jResX, _ := strconv.Atoi(jSplitRes[0])
|
|
||||||
jResY, _ := strconv.Atoi(jSplitRes[1])
|
|
||||||
|
|
||||||
return iResX+iResY < jResX+jResY
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubtitlesByLocale sorts subtitles after their locale.
|
|
||||||
type SubtitlesByLocale []*crunchyroll.Subtitle
|
|
||||||
|
|
||||||
func (sbl SubtitlesByLocale) Len() int {
|
|
||||||
return len(sbl)
|
|
||||||
}
|
|
||||||
func (sbl SubtitlesByLocale) Swap(i, j int) {
|
|
||||||
sbl[i], sbl[j] = sbl[j], sbl[i]
|
|
||||||
}
|
|
||||||
func (sbl SubtitlesByLocale) Less(i, j int) bool {
|
|
||||||
return LocaleLanguage(sbl[i].Locale) < LocaleLanguage(sbl[j].Locale)
|
|
||||||
}
|
|
||||||
280
video.go
280
video.go
|
|
@ -1,280 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type video struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ExternalID string `json:"external_id"`
|
|
||||||
|
|
||||||
Description string `json:"description"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
SlugTitle string `json:"slug_title"`
|
|
||||||
|
|
||||||
Images struct {
|
|
||||||
PosterTall [][]Image `json:"poster_tall"`
|
|
||||||
PosterWide [][]Image `json:"poster_wide"`
|
|
||||||
} `json:"images"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Video is the base for Movie and Season.
|
|
||||||
type Video interface{}
|
|
||||||
|
|
||||||
// Movie contains information about a movie.
|
|
||||||
type Movie struct {
|
|
||||||
video
|
|
||||||
Video
|
|
||||||
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
children []*MovieListing
|
|
||||||
|
|
||||||
// not generated when calling MovieFromID.
|
|
||||||
MovieListingMetadata struct {
|
|
||||||
AvailabilityNotes string `json:"availability_notes"`
|
|
||||||
AvailableOffline bool `json:"available_offline"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
ExtendedDescription string `json:"extended_description"`
|
|
||||||
FirstMovieID string `json:"first_movie_id"`
|
|
||||||
IsDubbed bool `json:"is_dubbed"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
IsPremiumOnly bool `json:"is_premium_only"`
|
|
||||||
IsSubbed bool `json:"is_subbed"`
|
|
||||||
MatureRatings []string `json:"mature_ratings"`
|
|
||||||
MovieReleaseYear int `json:"movie_release_year"`
|
|
||||||
SubtitleLocales []LOCALE `json:"subtitle_locales"`
|
|
||||||
} `json:"movie_listing_metadata"`
|
|
||||||
|
|
||||||
Playback string `json:"playback"`
|
|
||||||
|
|
||||||
PromoDescription string `json:"promo_description"`
|
|
||||||
PromoTitle string `json:"promo_title"`
|
|
||||||
SearchMetadata struct {
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieFromID returns a movie by its api id.
|
|
||||||
func MovieFromID(crunchy *Crunchyroll, id string) (*Movie, error) {
|
|
||||||
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
movieListing := &Movie{
|
|
||||||
crunchy: crunchy,
|
|
||||||
}
|
|
||||||
movieListing.ID = id
|
|
||||||
if err = decodeMapToStruct(jsonBody, movieListing); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return movieListing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieListing returns all videos corresponding with the movie.
|
|
||||||
func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
|
|
||||||
if m.children != nil {
|
|
||||||
return m.children, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := m.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
m.crunchy.Config.Bucket,
|
|
||||||
m.ID,
|
|
||||||
m.crunchy.Locale,
|
|
||||||
m.crunchy.Config.Signature,
|
|
||||||
m.crunchy.Config.Policy,
|
|
||||||
m.crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
movieListing := &MovieListing{
|
|
||||||
crunchy: m.crunchy,
|
|
||||||
}
|
|
||||||
if err = decodeMapToStruct(item, movieListing); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
movieListings = append(movieListings, movieListing)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.crunchy.cache {
|
|
||||||
m.children = movieListings
|
|
||||||
}
|
|
||||||
return movieListings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Series contains information about an anime series.
|
|
||||||
type Series struct {
|
|
||||||
video
|
|
||||||
Video
|
|
||||||
|
|
||||||
crunchy *Crunchyroll
|
|
||||||
|
|
||||||
children []*Season
|
|
||||||
|
|
||||||
PromoDescription string `json:"promo_description"`
|
|
||||||
PromoTitle string `json:"promo_title"`
|
|
||||||
|
|
||||||
AvailabilityNotes string `json:"availability_notes"`
|
|
||||||
EpisodeCount int `json:"episode_count"`
|
|
||||||
ExtendedDescription string `json:"extended_description"`
|
|
||||||
IsDubbed bool `json:"is_dubbed"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
IsSimulcast bool `json:"is_simulcast"`
|
|
||||||
IsSubbed bool `json:"is_subbed"`
|
|
||||||
MatureBlocked bool `json:"mature_blocked"`
|
|
||||||
MatureRatings []string `json:"mature_ratings"`
|
|
||||||
SeasonCount int `json:"season_count"`
|
|
||||||
|
|
||||||
// not generated when calling SeriesFromID.
|
|
||||||
SearchMetadata struct {
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeriesFromID returns a series by its api id.
|
|
||||||
func SeriesFromID(crunchy *Crunchyroll, id string) (*Series, error) {
|
|
||||||
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
crunchy.Config.Bucket,
|
|
||||||
id,
|
|
||||||
crunchy.Locale,
|
|
||||||
crunchy.Config.Signature,
|
|
||||||
crunchy.Config.Policy,
|
|
||||||
crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
series := &Series{
|
|
||||||
crunchy: crunchy,
|
|
||||||
}
|
|
||||||
series.ID = id
|
|
||||||
if err = decodeMapToStruct(jsonBody, series); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return series, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddToWatchlist adds the current episode to the watchlist.
|
|
||||||
// Will return an RequestError with the response status code of 409 if the series was already on the watchlist before.
|
|
||||||
func (s *Series) AddToWatchlist() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s?locale=%s", s.crunchy.Config.AccountID, s.crunchy.Locale)
|
|
||||||
body, _ := json.Marshal(map[string]string{"content_id": s.ID})
|
|
||||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
|
||||||
_, err = s.crunchy.requestFull(req)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFromWatchlist removes the current episode from the watchlist.
|
|
||||||
// Will return an RequestError with the response status code of 404 if the series was not on the watchlist before.
|
|
||||||
func (s *Series) RemoveFromWatchlist() error {
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/watchlist/%s/%s?locale=%s", s.crunchy.Config.AccountID, s.ID, s.crunchy.Locale)
|
|
||||||
_, err := s.crunchy.request(endpoint, http.MethodDelete)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Similar returns similar series and movies to the current series within the given limit.
|
|
||||||
func (s *Series) Similar(limit uint) (ss []*Series, m []*Movie, err error) {
|
|
||||||
similarToEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/%s/similar_to?guid=%s&n=%d&locale=%s",
|
|
||||||
s.crunchy.Config.AccountID, s.ID, limit, s.crunchy.Locale)
|
|
||||||
resp, err := s.crunchy.request(similarToEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to parse 'similar_to' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
switch item.(map[string]interface{})["type"] {
|
|
||||||
case MediaTypeSeries:
|
|
||||||
series := &Series{
|
|
||||||
crunchy: s.crunchy,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item.(map[string]interface{})["series_metadata"].(map[string]interface{}), series); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ss = append(ss, series)
|
|
||||||
case MediaTypeMovie:
|
|
||||||
movie := &Movie{
|
|
||||||
crunchy: s.crunchy,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, movie); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m = append(m, movie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seasons returns all seasons of a series.
|
|
||||||
func (s *Series) Seasons() (seasons []*Season, err error) {
|
|
||||||
if s.children != nil {
|
|
||||||
return s.children, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
|
|
||||||
s.crunchy.Config.Bucket,
|
|
||||||
s.ID,
|
|
||||||
s.crunchy.Locale,
|
|
||||||
s.crunchy.Config.Signature,
|
|
||||||
s.crunchy.Config.Policy,
|
|
||||||
s.crunchy.Config.KeyPairID), http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
season := &Season{
|
|
||||||
crunchy: s.crunchy,
|
|
||||||
}
|
|
||||||
if err = decodeMapToStruct(item, season); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seasons = append(seasons, season)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.crunchy.cache {
|
|
||||||
s.children = seasons
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
16
wallpaper.go
16
wallpaper.go
|
|
@ -1,16 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Wallpaper contains a wallpaper name which can be set via Account.ChangeWallpaper.
|
|
||||||
type Wallpaper string
|
|
||||||
|
|
||||||
// TinyUrl returns the url to the wallpaper in low resolution.
|
|
||||||
func (w *Wallpaper) TinyUrl() string {
|
|
||||||
return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/360x115/%s", *w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BigUrl returns the url to the wallpaper in high resolution.
|
|
||||||
func (w *Wallpaper) BigUrl() string {
|
|
||||||
return fmt.Sprintf("https://static.crunchyroll.com/assets/wallpaper/1920x400/%s", *w)
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WatchHistory returns the history of watched episodes based on the currently logged in account from the given page with the given size.
|
|
||||||
func (c *Crunchyroll) WatchHistory(page uint, size uint) (e []*HistoryEpisode, err error) {
|
|
||||||
watchHistoryEndpoint := fmt.Sprintf("https://beta-api.crunchyroll.com/content/v1/watch-history/%s?page=%d&page_size=%d&locale=%s",
|
|
||||||
c.Config.AccountID, page, size, c.Locale)
|
|
||||||
resp, err := c.request(watchHistoryEndpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&jsonBody); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse 'watch-history' response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range jsonBody["items"].([]interface{}) {
|
|
||||||
panel := item.(map[string]interface{})["panel"]
|
|
||||||
|
|
||||||
episode := &Episode{
|
|
||||||
crunchy: c,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(panel, episode); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
historyEpisode := &HistoryEpisode{
|
|
||||||
Episode: episode,
|
|
||||||
}
|
|
||||||
if err := decodeMapToStruct(item, historyEpisode); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
e = append(e, historyEpisode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
99
watchlist.go
99
watchlist.go
|
|
@ -1,99 +0,0 @@
|
||||||
package crunchyroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WatchlistLanguageType represents a filter type to filter Crunchyroll.Watchlist entries after sub or dub.
|
|
||||||
type WatchlistLanguageType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
WatchlistLanguageSubbed WatchlistLanguageType = iota + 1
|
|
||||||
WatchlistLanguageDubbed
|
|
||||||
)
|
|
||||||
|
|
||||||
type WatchlistOrderType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
WatchlistOrderAsc = "asc"
|
|
||||||
WatchlistOrderDesc = "desc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WatchlistOptions represents options for receiving the user watchlist.
|
|
||||||
type WatchlistOptions struct {
|
|
||||||
// Order specified whether the results should be order ascending or descending.
|
|
||||||
Order WatchlistOrderType
|
|
||||||
|
|
||||||
// OnlyFavorites specifies whether only episodes which are marked as favorite should be returned.
|
|
||||||
OnlyFavorites bool
|
|
||||||
|
|
||||||
// LanguageType specifies whether returning episodes should be only subbed or dubbed.
|
|
||||||
LanguageType WatchlistLanguageType
|
|
||||||
|
|
||||||
// ContentType specified whether returning videos should only be series episodes or movies.
|
|
||||||
// But tbh all movies I've searched on crunchy were flagged as series too, so this
|
|
||||||
// parameter is kinda useless.
|
|
||||||
ContentType MediaType
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watchlist returns the watchlist entries for the currently logged in user.
|
|
||||||
func (c *Crunchyroll) Watchlist(options WatchlistOptions, limit uint) ([]*WatchlistEntry, error) {
|
|
||||||
values := url.Values{}
|
|
||||||
if options.Order == "" {
|
|
||||||
options.Order = WatchlistOrderDesc
|
|
||||||
}
|
|
||||||
values.Set("order", string(options.Order))
|
|
||||||
if options.OnlyFavorites {
|
|
||||||
values.Set("only_favorites", "true")
|
|
||||||
}
|
|
||||||
switch options.LanguageType {
|
|
||||||
case WatchlistLanguageSubbed:
|
|
||||||
values.Set("is_subbed", "true")
|
|
||||||
case WatchlistLanguageDubbed:
|
|
||||||
values.Set("is_dubbed", "true")
|
|
||||||
}
|
|
||||||
values.Set("n", strconv.Itoa(int(limit)))
|
|
||||||
values.Set("locale", string(c.Locale))
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("https://beta.crunchyroll.com/content/v1/%s/watchlist?%s", c.Config.AccountID, values.Encode())
|
|
||||||
resp, err := c.request(endpoint, http.MethodGet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var jsonBody map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&jsonBody)
|
|
||||||
|
|
||||||
var watchlistEntries []*WatchlistEntry
|
|
||||||
if err := decodeMapToStruct(jsonBody["items"], &watchlistEntries); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range watchlistEntries {
|
|
||||||
switch entry.Panel.Type {
|
|
||||||
case WatchlistEntryEpisode:
|
|
||||||
entry.Panel.EpisodeMetadata.crunchy = c
|
|
||||||
case WatchlistEntrySeries:
|
|
||||||
entry.Panel.SeriesMetadata.crunchy = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return watchlistEntries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WatchlistEntry contains information about an entry on the watchlist.
|
|
||||||
type WatchlistEntry struct {
|
|
||||||
Panel Panel `json:"panel"`
|
|
||||||
|
|
||||||
New bool `json:"new"`
|
|
||||||
NewContent bool `json:"new_content"`
|
|
||||||
IsFavorite bool `json:"is_favorite"`
|
|
||||||
NeverWatched bool `json:"never_watched"`
|
|
||||||
CompleteStatus bool `json:"complete_status"`
|
|
||||||
Playahead uint `json:"playahead"`
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue