Initial commit

This commit is contained in:
ByteDream 2021-08-10 00:32:17 +02:00
commit 5f1d811c66
23 changed files with 3612 additions and 0 deletions

61
LICENSE Normal file
View file

@ -0,0 +1,61 @@
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License.
“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”.
The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.

17
Makefile Normal file
View file

@ -0,0 +1,17 @@
VERSION=1.0
BINARY_NAME=crunchy
VERSION_BINARY_NAME=$(BINARY_NAME)-v$(VERSION)
build:
cd cmd/crunchyroll && CGO_ENABLED=0 go build -o $(BINARY_NAME)
mv cmd/crunchyroll/$(BINARY_NAME) .
test:
go test -v .
release:
cd cmd/crunchyroll && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(VERSION_BINARY_NAME)_linux
cd cmd/crunchyroll && GOOS=windows GOARCH=amd64 go build -o $(VERSION_BINARY_NAME)_windows.exe
cd cmd/crunchyroll && GOOS=darwin GOARCH=amd64 go build -o $(VERSION_BINARY_NAME)_darwin
mv cmd/crunchyroll/$(VERSION_BINARY_NAME)_* .

331
README.md Normal file
View file

@ -0,0 +1,331 @@
# crunchyroll
A [Go](https://golang.org) library & cli for the undocumented [crunchyroll](https://www.crunchyroll.com) api.
**You surely need a crunchyroll premium account to get full (api) access.**
<p align="center">
<a href="https://github.com/ByteDream/crunchyroll">
<img src="https://img.shields.io/github/languages/code-size/ByteDream/crunchyroll?style=flat-square" alt="Code size">
</a>
<a href="https://github.com/ByteDream/crunchyroll/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/ByteDream/crunchyroll?style=flat-square" alt="License">
</a>
<a href="https://golang.org">
<img src="https://img.shields.io/github/go-mod/go-version/ByteDream/crunchyroll?style=flat-square" alt="Go version">
</a>
<a href="https://github.com/ByteDream/crunchyroll/releases/latest">
<img src="https://img.shields.io/github/v/release/ByteDream/crunchyroll?style=flat-square" alt="Release">
</a>
</p>
<p align="center">
<a href="#%EF%B8%8F-cli">CLI 🖥️</a>
<a href="#-library">Library 📚</a>
<a href="#-credits">Credits 🙏</a>
<a href="#-license">License ⚖</a>
</p>
## 🖥️ CLI
#### ✨ Features
- Download single videos and entire series from [crunchyroll](https://www.crunchyroll.com)
#### Get the executable
- 📥 Download the latest binaries [here](https://github.com/ByteDream/crunchyroll/releases/latest) or get it from below
- [Linux (x64)](https://github.com/ByteDream/crunchyroll/releases/download/v1.0/crunchy-v1.0_linux)
- [Windows (x64)](https://github.com/ByteDream/crunchyroll/releases/download/v1.0/crunchy-v1.0_windows.exe)
- [MacOS (x64)](https://github.com/ByteDream/crunchyroll/releases/download/v1.0/crunchy-v1.0_darwin)
- 🛠 Build it yourself
- use `make` (requires `go` to be installed)
```
$ git clone https://github.com/ByteDream/crunchyroll
$ cd crunchyroll
$ make
```
- use `go`
```
$ git clone https://github.com/ByteDream/crunchyroll
$ cd crunchyroll/cmd/crunchyroll
$ go build -o crunchy
```
### 📝 Examples
#### Login
Before you can do something, you have to login first.
This can be performed via crunchyroll account email and password
```
$ crunchy login user@example.com password
```
or via session id
```
$ crunchy login --session-id 8e9gs135defhga790dvrf2i0eris8gts
```
#### Download
**With the cli you can download single videos or entire series.**
By default the cli tries to download the episode with your system language as audio.
If no streams with your system language are available, the video will be downloaded with japanese audio and hardsubbed subtitles in your system language.
**If your system language is not supported, an error message will be displayed and en-US (american english) will be chosen as language.**
```
$ crunchy download https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575
```
With `-r best` the video(s) will have the best available resolution (mostly 1920x1080 / Full HD).
```
$ crunchy download -r best https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575
```
The file is by default saved as a `.ts` (mpeg transport stream) file.
`.ts` files may can't be played or are looking very weird (it depends on the video player you are using).
With the `-o` flag, you can change the name (and file ending) of the output file.
So if you want to save it as, for example, `mp4` file, just name it `whatever.mp4`.
**You need [ffmpeg](https://ffmpeg.org) to store the video in other file formats.**
```
$ crunchy download -o "daaaaaaaaaaaaaaaarling.ts" https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575
```
With the `--audio` flag you can specify which audio the video should have and with `--subtitle` which subtitle it should have.
Type `crunchy help download` to see all available locales.
```
$ crunchy download --audio ja-JP --subtitle de-DE https://www.crunchyroll.com/darling-in-the-franxx
```
##### Flags
- `--audio` » forces audio of the video(s)
- `--subtitle` » forces subtitle of the video(s)
- `--no-hardsub` » forces that the subtitles are stored as a separate file and are not directly embedded into the video
- `-d`, `--directory` » directory to download the video(s) to
- `-o`, `--output` » name of the output file
- `-r`, `--resolution` » the resolution of the video(s). `best` for best resolution, `worst` for worst
- `--alternative-progress` » shows an alternative, not so user-friendly progress instead of the progress bar
#### Help
- General help
```
$ crunchy help
```
- Login help
```
$ crunchy help login
```
- Download help
```
$ crunchy help download
```
#### Global flags
These flags you can use across every sub-command
- `-q`, `--quiet` » disables all output
- `-v`, `--verbote` » shows additional debug output
- `--color` » adds color to the output (works only on not windows systems)
- `-p`, `--proxy` » use a proxy to hide your ip / redirect your traffic
- `-l`, `--locale` » the language to display video specific things like the title. default is your system language
## 📚 Library
Download the library via `go get`
```
$ go get github.com/ByteDream/crunchyroll
```
### 📝 Examples
```go
func main() {
// login with credentials
crunchy, err := crunchyroll.LoginWithCredentials("user@example.com", "password", crunchyroll.US, http.DefaultClient)
if err != nil {
panic(err)
}
// finds a series or movie by a crunchyroll link
video, err := crunchy.FindVideo("https://www.crunchyroll.com/darling-in-the-franxx")
if err != nil {
panic(err)
}
series := video.(*crunchyroll.Series)
seasons, err := series.Seasons()
if err != nil {
panic(err)
}
fmt.Printf("Found %d seasons for series %s\n", len(seasons), series.Title)
// search `Darling` and return 20 results
series, movies, err := crunchy.Search("Darling", 20)
if err != nil {
panic(err)
}
fmt.Printf("Found %d series and %d movies for query `Darling`\n", len(series), len(movies))
}
```
```go
func main() {
crunchy, err := crunchyroll.LoginWithSessionID("8e9gs135defhga790dvrf2i0eris8gts", crunchyroll.US, http.DefaultClient)
if err != nil {
panic(err)
}
// returns an episode slice with all episodes which are matching the given url.
// the episodes in the returning slice differs from the underlying streams, but are all pointing to the first ditf episode
episodes, err := crunchy.FindEpisode("https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575")
if err != nil {
panic(err)
}
fmt.Printf("Found %d episodes\n", len(episodes))
}
```
<h4 align="center">Structure</h4>
Because of the apis structure, it can lead very fast much redundant code for simple tasks, like getting all episodes
with japanese audio and german subtitle. For this case and some other, the api has a utility called `Structure` in its utils.
```go
func main() {
crunchy, err := crunchyroll.LoginWithCredentials("user@example.com", "password", crunchyroll.US, http.DefaultClient)
if err != nil {
panic(err)
}
// search `Darling` and return 20 results (series and movies) or less
series, movies, err := crunchy.Search("Darling", 20)
if err != nil {
panic(err)
}
fmt.Printf("Found %d series and %d movies for search query `Darling`\n", len(series), len(movies))
seasons, err := series[0].Seasons()
if err != nil {
panic(err)
}
// in the crunchyroll.utils package, you find some structs which can be used to simplify tasks.
// you can recursively search all underlying content
seriesStructure := utils.NewSeasonStructure(seasons)
// this returns every format of all the above given seasons
formats, err := seriesStructure.Formats()
if err != nil {
panic(err)
}
fmt.Printf("Found %d formats\n", len(formats))
filteredFormats, err := seriesStructure.FilterFormatsByLocales(crunchyroll.JP, crunchyroll.DE, true)
if err != nil {
panic(err)
}
fmt.Printf("Found %d formats with japanese audio and hardsubbed german subtitles\n", len(filteredFormats))
// reverse sorts the formats after their resolution by calling a sort type which is also defined in the api utils
// and stores the format with the highest resolution in a variable
sort.Sort(sort.Reverse(utils.FormatsByResolution(filteredFormats)))
format := formats[0]
// get the episode from which the format is a child
episode, err := seriesStructure.FilterEpisodeByFormat(format)
if err != nil {
panic(err)
}
file, err := os.Create(fmt.Sprintf("%s.ts", episode.Title))
if err != nil {
panic(err)
}
// download the format to the file
if err := format.Download(file, nil); err != nil {
panic(err)
}
fmt.Printf("Downloaded %s with %s resolution and %.2f fps as %s\n", episode.Title, format.Video.Resolution, format.Video.FPS, file.Name())
// for more useful structure function just let your IDE's autocomplete make its thing
}
```
As you can see in the example above, most of the `crunchyroll.utils` Structure functions are returning errors. There is
a build-in functionality with are avoiding causing the most errors and let you safely ignore them as well.
**Note that errors still can appear**
```go
func main() {
crunchy, err := crunchyroll.LoginWithCredentials("user@example.com", "password", crunchyroll.US, http.DefaultClient)
if err != nil {
panic(err)
}
foundEpisodes, err := crunchy.FindEpisode("https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575")
if err != nil {
panic(err)
}
episodeStructure := utils.NewEpisodeStructure(foundEpisodes)
// this function recursively calls all api endpoints, receives everything and stores it in memory,
// so that after executing this, no more request to the crunchyroll server has to be made.
// note that it could cause much network load while running this method.
//
// you should check the InitAllState before, because InitAll could have been already called or
// another function has the initialization as side effect and re-initializing everything
// will change every pointer in the struct which can cause massive problems afterwards.
if !episodeStructure.InitAllState() {
if err := episodeStructure.InitAll(); err != nil {
panic(err)
}
}
formats, _ := episodeStructure.Formats()
streams, _ := episodeStructure.Streams()
episodes, _ := episodeStructure.Episodes()
fmt.Printf("Initialized %d formats, %d streams and %d episodes\n", len(formats), len(streams), len(episodes))
}
```
### Tests
You can also run test to see if the api works correctly.
Before doing this, make sure to either set your crunchyroll email and password or sessions as environment variable.
The email variable has to be named `EMAIL` and the password variable `PASSWORD`. If you want to use your session id, the variable must be named `SESSION_ID`.
You can run the test via `make`
```
$ make test
```
or via `go` directly
```
$ go test .
```
# 🙏 Credits
### [Kamyroll-Python](https://github.com/hyugogirubato/Kamyroll-Python)
- Extracted all api endpoints and the login process from this
### [m3u8](https://github.com/oopsguy/m3u8)
- Decrypting mpeg stream files
### All libraries
- [m3u8](https://github.com/grafov/m3u8) (not the m3u8 library from above) » mpeg stream info library
- [cobra](https://github.com/spf13/cobra) » cli library
# ⚖ 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.

View file

@ -0,0 +1,570 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"github.com/ByteDream/crunchyroll"
"github.com/ByteDream/crunchyroll/utils"
"github.com/grafov/m3u8"
"github.com/spf13/cobra"
"os"
"os/exec"
"os/signal"
"path"
"sort"
"strconv"
"strings"
"syscall"
"text/template"
)
// sigusr1 is actually syscall.SIGUSR1, but because has no signal (or very less) it has to be defined manually
var sigusr1 = syscall.Signal(0xa)
var (
audioFlag string
subtitleFlag string
noHardsubFlag bool
directoryFlag string
outputFlag string
resolutionFlag string
alternativeProgressFlag bool
)
var cleanup [2]string
var getCmd = &cobra.Command{
Use: "download",
Short: "Download a video",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
loadCrunchy()
download(args)
},
}
func init() {
rootCmd.AddCommand(getCmd)
getCmd.Flags().StringVar(&audioFlag, "audio", "", "The locale of the audio. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
getCmd.Flags().StringVar(&subtitleFlag, "subtitle", "", "The locale of the subtitle. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
getCmd.Flags().BoolVar(&noHardsubFlag, "no-hardsub", false, "Same as `--sub`, but the subtitles are not stored in the video itself, but in a separate file")
cwd, _ := os.Getwd()
getCmd.Flags().StringVarP(&directoryFlag, "directory", "d", cwd, "The directory to download the file to")
getCmd.Flags().StringVarP(&outputFlag, "output", "o", "{{.Title}}.ts", "Name of the output file\n"+
"If you use the following things in the name, the will get replaced"+
"\t{{.Title}} » Title of the video\n"+
"\t{{.Resolution}} » Resolution of the video\n"+
"\t{{.FPS}} » Frame Rate of the video\n"+
"\t{{.Audio}} » Audio locale of the video\n"+
"\t{{.Subtitle}} » Subtitle locale of the video\n")
getCmd.Flags().StringVarP(&resolutionFlag, "resolution", "r", "best", "res")
getCmd.Flags().BoolVar(&alternativeProgressFlag, "alternative-progress", false, "Shows an alternative, not so user-friendly progress instead of the progress bar")
}
type information struct {
Title string `json:"title"`
OriginalURL string `json:"original_url"`
DownloadURL string `json:"download_url"`
Resolution string `json:"resolution"`
FPS float64 `json:"fps"`
Audio crunchyroll.LOCALE `json:"audio"`
Subtitle crunchyroll.LOCALE `json:"subtitle"`
Hardsub bool `json:"hardsub"`
}
func download(urls []string) {
if path.Ext(outputFlag) != ".ts" && !hasFFmpeg() {
out.Fatalf("The file ending for the output file (%s) is not `.ts`. "+
"Install ffmpeg (https://ffmpeg.org/download.html) use other media file endings (e.g. `.mp4`)\n", outputFlag)
}
var allFormats []*crunchyroll.Format
var allTitles []string
var allURLs []string
for i, url := range urls {
var failed bool
out.StartProgressf("Parsing url %d", i+1)
if video, err1 := crunchy.FindVideo(url); err1 == nil {
out.Debugf("Pre-parsed url %d as video\n", i+1)
if formats, titles := parseVideo(video, url); formats != nil {
allFormats = append(allFormats, formats...)
allTitles = append(allTitles, titles...)
for range formats {
allURLs = append(allURLs, url)
}
} else {
failed = true
}
} else if episodes, err2 := crunchy.FindEpisode(url); err2 == nil {
out.Debugf("Parsed url %d as episode\n", i+1)
out.Debugf("Found %d episode types\n", len(episodes))
if format, title := parseEpisodes(episodes, url); format != nil {
allFormats = append(allFormats, format)
allTitles = append(allTitles, title)
allURLs = append(allURLs, url)
} else {
failed = true
}
} else {
out.EndProgressf(false, "Could not parse url %d, skipping\n", i+1)
out.Debugf("Parse error 1: %s\n", err1)
out.Debugf("Parse error 2: %s\n", err2)
continue
}
if !failed {
out.EndProgressf(true, "Parsed url %d successful\n", i+1)
} else {
out.EndProgressf(false, "Failed to parse url %d (the url is valid but some kind of error which is surely shown caused the failure)", i+1)
}
}
out.Debugf("%d of %d urls could be parsed\n", len(allURLs), len(urls))
out.Empty()
if len(allFormats) == 0 {
out.Fatalf("Nothing to download, aborting\n")
}
out.Infof("Downloads:")
for i, format := range allFormats {
video := format.Video
out.Infof("\t%d. %s » %spx, %.2f FPS, %s audio\n", i+1, allTitles[i], video.Resolution, video.FrameRate, utils.LocaleLanguage(format.AudioLocale))
}
var tmpl *template.Template
var err error
tmpl, err = template.New("").Parse(outputFlag)
if err == nil {
var buff bytes.Buffer
if err := tmpl.Execute(&buff, allFormats[0].Video); err == nil {
if buff.String() == outputFlag {
tmpl = nil
}
}
}
if fileInfo, stat := os.Stat(directoryFlag); err == nil {
if !fileInfo.IsDir() {
out.Fatalf("%s (given from the `-d`/`--directory` flag) is not a directory\n", directoryFlag)
}
} else if os.IsNotExist(stat) {
if err := os.MkdirAll(directoryFlag, 0777); err != nil {
out.Fatalf("Failed to create directory which was given from the `-d`/`--directory` flag: %s\n", err)
}
} else {
out.Fatalf("Failed to get information for via `-d`/`--directory` flag the given file / directory: %s", err)
}
var success int
for i, format := range allFormats {
var subtitle crunchyroll.LOCALE
if subtitleFlag != "" {
subtitle = localeToLOCALE(subtitleFlag)
}
info := information{
Title: allTitles[i],
OriginalURL: allURLs[i],
DownloadURL: format.Video.URI,
Resolution: format.Video.Resolution,
FPS: format.Video.FrameRate,
Audio: format.AudioLocale,
Subtitle: subtitle,
}
if verboseFlag {
fmtOptionsBytes, err := json.Marshal(info)
if err != nil {
fmtOptionsBytes = make([]byte, 0)
}
out.Debugf("Information (json): %s", string(fmtOptionsBytes))
}
var baseFilename string
if tmpl != nil {
var buff bytes.Buffer
if err := tmpl.Execute(&buff, info); err == nil {
baseFilename = buff.String()
} else {
out.Fatalf("Could not convert filename (%s), aborting\n", err)
}
} else {
baseFilename = outputFlag
}
out.Empty()
if downloadFormat(format, directoryFlag, baseFilename, info) {
success++
}
}
out.Empty()
out.Infof("Downloaded %d out of %d videos successful\n", success, len(allFormats))
}
func parseVideo(video crunchyroll.Video, url string) (parsedFormats []*crunchyroll.Format, titles []string) {
var rootTitle string
var orderedFormats [][]*crunchyroll.Format
var videoStructure utils.VideoStructure
switch video.(type) {
case *crunchyroll.Series:
out.Debugf("Parsed url as series\n")
series := video.(*crunchyroll.Series)
seasons, err := series.Seasons()
if err != nil {
out.Errf("Could not get any season of %s (%s): %s. Aborting\n", series.Title, url, err.Error())
return
}
out.Debugf("Found %d seasons\n", len(seasons))
seasonsStructure := utils.NewSeasonStructure(seasons)
if err := seasonsStructure.InitAll(); err != nil {
out.Errf("Failed to initialize %s (%s): %s. Aborting\n", series.Title, url, err.Error())
return
}
out.Debugf("Initialized %s\n", series.Title)
rootTitle = series.Title
orderedFormats, _ = seasonsStructure.OrderFormatsByEpisodeNumber()
videoStructure = seasonsStructure.EpisodeStructure
case *crunchyroll.Movie:
out.Debugf("Parsed url as movie\n")
movie := video.(*crunchyroll.Movie)
movieListings, err := movie.MovieListing()
if err != nil {
out.Errf("Failed to get movie of %s (%s)\n", movie.Title, url)
return
}
out.Debugf("Parsed %d movie listenings\n", len(movieListings))
movieListingStructure := utils.NewMovieListingStructure(movieListings)
if err := movieListingStructure.InitAll(); err != nil {
out.Errf("Failed to initialize %s (%s): %s. Aborting\n", movie.Title, url, err.Error())
return
}
rootTitle = movie.Title
unorderedFormats, _ := movieListingStructure.Formats()
orderedFormats = append(orderedFormats, unorderedFormats)
videoStructure = movieListingStructure
}
// out.Debugf("Found %d formats\n", len(unorderedFormats))
out.Debugf("Found %d different episodes\n", len(orderedFormats))
for j, formats := range orderedFormats {
if format := findFormat(formats); format != nil {
var title string
switch videoStructure.(type) {
case *utils.EpisodeStructure:
episode, _ := videoStructure.(*utils.EpisodeStructure).GetEpisodeByFormat(format)
title = episode.Title
case *utils.MovieListingStructure:
movieListing, _ := videoStructure.(*utils.MovieListingStructure).GetMovieListingByFormat(format)
title = movieListing.Title
}
parsedFormats = append(parsedFormats, format)
titles = append(titles, title)
out.Debugf("Successful parsed format %d for %s\n", j+1, rootTitle)
}
}
return
}
func parseEpisodes(episodes []*crunchyroll.Episode, url string) (*crunchyroll.Format, string) {
episodeStructure := utils.NewEpisodeStructure(episodes)
if err := episodeStructure.InitAll(); err != nil {
out.EndProgressf(false, "Failed to initialize %s (%s): %s, skipping\n", episodes[0].Title, url, err)
return nil, ""
}
formats, _ := episodeStructure.Formats()
out.Debugf("Found %d formats\n", len(formats))
if format := findFormat(formats); format != nil {
episode, _ := episodeStructure.GetEpisodeByFormat(format)
return format, episode.Title
}
return nil, ""
}
func findFormat(formats []*crunchyroll.Format) (format *crunchyroll.Format) {
formatStructure := utils.NewFormatStructure(formats)
var audioLocale, subtitleLocale crunchyroll.LOCALE
if audioFlag != "" {
audioLocale = localeToLOCALE(audioFlag)
} else {
audioLocale = localeToLOCALE(systemLocale())
}
if subtitleFlag != "" {
subtitleLocale = localeToLOCALE(subtitleFlag)
}
if audioFlag == "" {
var dubOk bool
availableDub, _, _, _ := formatStructure.AvailableLocales(true)
for _, dub := range availableDub {
if dub == audioLocale {
dubOk = true
break
}
}
if !dubOk {
if audioFlag != systemLocale() {
out.EndProgressf(false, "No stream with audio locale `%s` is available, skipping\n", audioLocale)
return nil
}
out.Errf("No stream with default audio locale `%s` is available, using hardsubbed %s with subtitle locale %s\n", audioLocale, crunchyroll.JP, systemLocale())
audioLocale = crunchyroll.JP
if subtitleFlag == "" {
subtitleLocale = localeToLOCALE(systemLocale())
}
}
}
var dubOk, subOk bool
availableDub, availableSub, _, _ := formatStructure.AvailableLocales(true)
for _, dub := range availableDub {
if dub == audioLocale {
dubOk = true
break
}
}
if !dubOk {
if audioFlag == "" {
audioLocale = crunchyroll.JP
if subtitleFlag == "" {
subtitleLocale = localeToLOCALE(systemLocale())
out.Errf("No stream with default audio locale `%s` is available, using hardsubbed %s with subtitle locale %s\n", audioLocale, crunchyroll.JP, subtitleLocale)
}
}
for _, dub := range availableDub {
if dub == audioLocale {
dubOk = true
break
}
}
}
if subtitleLocale != "" {
for _, sub := range availableSub {
if sub == subtitleLocale {
subOk = true
break
}
}
} else {
subOk = true
}
if !dubOk {
out.Errf("Could not find any video with `%s` audio locale\n", audioLocale)
}
if !subOk {
out.Errf("Could not find any video with `%s` subtitle locale\n", subtitleLocale)
}
if !dubOk || !subOk {
return nil
}
formats, err := formatStructure.FilterFormatsByLocales(audioLocale, subtitleLocale, !noHardsubFlag)
if err != nil {
out.Errln("Failed to get matching format. Try to change the `--audio` or `--subtitle` flag")
return
}
if resolutionFlag == "best" || resolutionFlag == "" {
sort.Sort(sort.Reverse(utils.FormatsByResolution(formats)))
format = formats[0]
} else if resolutionFlag == "worst" {
sort.Sort(utils.FormatsByResolution(formats))
format = formats[0]
} else {
for _, f := range formats {
if f.Video.Resolution == resolutionFlag {
format = f
break
}
}
}
subtitleFlag = string(subtitleLocale)
return
}
func downloadFormat(format *crunchyroll.Format, dir, fname string, info information) (_ bool) {
filename := freeFileName(path.Join(dir, fname))
ext := path.Ext(filename)
out.Debugf("Download filename: %s\n", filename)
if filename != path.Join(dir, fname) {
out.Errf("The file %s already exist, renaming the download file to %s\n", path.Join(dir, fname), filename)
}
if ext != ".ts" {
if !hasFFmpeg() {
out.Fatalf("The file ending for the output file (%s) is not `.ts`. "+
"Install ffmpeg (https://ffmpeg.org/download.html) use other media file endings (e.g. `.mp4`)\n", filename)
}
out.Debugf("File will be converted via ffmpeg")
}
var subtitleFilename string
if noHardsubFlag {
subtitle, ok := utils.SubtitleByLocale(format, info.Subtitle)
if !ok {
out.Errf("Failed to get %s subtitles\n", info.Subtitle)
return false
}
subtitleFilename = freeFileName(path.Join(dir, fmt.Sprintf("%s.%s", strings.TrimRight(path.Base(filename), ext), subtitle.Format)))
out.Debugf("Subtitles will be saved as `%s`\n", subtitleFilename)
}
out.Infof("Downloading `%s` (%s) as `%s`\n", info.Title, info.OriginalURL, filename)
out.Infof("Audio: %s\n", info.Audio)
out.Infof("Subtitle: %s\n", info.Subtitle)
out.Infof("Hardsub: %v\n", format.Hardsub != "")
out.Infof("Resolution: %s\n", info.Resolution)
out.Infof("FPS: %.2f\n", info.FPS)
var err error
if ext == ".ts" {
file, err := os.Create(filename)
defer file.Close()
if err != nil {
out.Errf("Could not create file `%s` to download episode `%s` (%s): %s, skipping\n", filename, info.Title, info.OriginalURL, err)
return
}
cleanup[0] = filename
// removes all files in case of an unexpected exit
sigs := make(chan os.Signal)
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM, sigusr1)
go func() {
sig := <-sigs
os.RemoveAll(cleanup[1])
switch sig {
case os.Interrupt, syscall.SIGTERM:
os.Remove(cleanup[0])
os.Exit(1)
}
}()
err = format.Download(file, downloadProgress)
// make the goroutine stop
sigs <- sigusr1
} else {
tempDir, err := os.MkdirTemp("", "crunchy_")
if err != nil {
out.Errln("Failed to create temp download dir. Skipping")
return
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM, sigusr1)
go func() {
sig := <-sigs
os.RemoveAll(tempDir)
switch sig {
case os.Interrupt, syscall.SIGTERM:
os.Exit(1)
}
}()
var filenames []string
err = format.DownloadSegments(tempDir, 4, func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error {
filenames = append(filenames, file.Name())
return downloadProgress(segment, current, total, file, err)
})
sort.Slice(filenames, func(i, j int) bool {
iNum, err := strconv.Atoi(strings.Split(path.Base(filenames[i]), ".")[0])
if err != nil {
return false
}
jNum, err := strconv.Atoi(strings.Split(path.Base(filenames[j]), ".")[0])
if err != nil {
return false
}
return iNum < jNum
})
cmd := exec.Command("ffmpeg",
"-i", fmt.Sprintf("concat:%s", strings.Join(filenames, "|")),
"-codec", "copy",
filename)
err = cmd.Run()
sigs <- sigusr1
}
if err != nil {
out.Errln("Failed to download video, skipping")
} else {
if info.Subtitle == "" {
out.Infof("Downloaded `%s` as `%s` with %s audio locale\n", info.Title, filename, strings.ToLower(utils.LocaleLanguage(info.Audio)))
} else {
out.Infof("Downloaded `%s` as `%s` with %s audio locale and %s subtitle locale\n", info.Title, filename, strings.ToLower(utils.LocaleLanguage(info.Audio)), strings.ToLower(utils.LocaleLanguage(info.Subtitle)))
if subtitleFilename != "" {
file, err := os.Create(subtitleFilename)
if err != nil {
out.Errf("Failed to download subtitles: %s\n", err)
return false
} else {
subtitle, ok := utils.SubtitleByLocale(format, info.Subtitle)
if !ok {
out.Errln("Failed to get %s subtitles\n", info.Subtitle)
return false
}
if err := subtitle.Download(file); err != nil {
out.Errf("Failed to download subtitles: %s\n", err)
return false
}
out.Infof("Downloaded `%s` subtitles to `%s`\n", info.Subtitle, subtitleFilename)
}
}
}
}
return true
}
func downloadProgress(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error {
if cleanup[1] == "" && file != nil {
cleanup[1] = path.Dir(file.Name())
}
if !quietFlag {
percentage := float32(current) / float32(total) * 100
if alternativeProgressFlag {
out.Infof("Downloading %d/%d (%.2f%%) » %s", current, total, percentage, segment.URI)
} else {
progressWidth := float32(terminalWidth() - (14 + len(out.InfoLog.Prefix())) - (len(fmt.Sprint(total)))*2)
repeatCount := int(percentage / (float32(100) / progressWidth))
// it can be lower than zero when the terminal is very tiny
if repeatCount < 0 {
repeatCount = 0
}
// alternative:
// progressPercentage := strings.Repeat("█", repeatCount)
progressPercentage := (strings.Repeat("=", repeatCount) + ">")[1:]
fmt.Printf("\r%s[%-"+fmt.Sprint(progressWidth)+"s]%4d%% %8d/%d", out.InfoLog.Prefix(), progressPercentage, int(percentage), current, total)
}
}
return nil
}
func freeFileName(filename string) string {
ext := path.Ext(filename)
base := strings.TrimRight(filename, ext)
for j := 0; ; j++ {
if _, stat := os.Stat(filename); stat != nil && !os.IsExist(stat) {
break
}
filename = fmt.Sprintf("%s (%d)%s", base, j, ext)
}
return filename
}

View file

@ -0,0 +1,50 @@
package cmd
import (
"github.com/ByteDream/crunchyroll"
"github.com/spf13/cobra"
"io/ioutil"
)
var (
sessionIDFlag bool
)
var loginCmd = &cobra.Command{
Use: "login",
Short: "Login to crunchyroll",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
if sessionIDFlag {
return loginSessionID(args[0], false)
} else {
return loginCredentials(args[0], args[1])
}
},
}
func init() {
rootCmd.AddCommand(loginCmd)
loginCmd.Flags().BoolVar(&sessionIDFlag, "session-id", false, "session id")
}
func loginCredentials(email, password string) error {
out.Debugln("Logging in via credentials")
session, err := crunchyroll.LoginWithCredentials(email, password, locale, client)
if err != nil {
return err
}
return loginSessionID(session.SessionID, true)
}
func loginSessionID(sessionID string, alreadyChecked bool) error {
if !alreadyChecked {
out.Debugln("Logging in via session id")
if _, err := crunchyroll.LoginWithSessionID(sessionID, locale, client); err != nil {
return err
}
}
out.Infoln("Due to security reasons, you have to login again on the next reboot")
return ioutil.WriteFile(sessionIDPath, []byte(sessionID), 0777)
}

View file

@ -0,0 +1,68 @@
package cmd
import (
"github.com/ByteDream/crunchyroll"
"github.com/spf13/cobra"
"net/http"
"os"
"runtime"
"runtime/debug"
)
var (
client *http.Client
locale crunchyroll.LOCALE
crunchy *crunchyroll.Crunchyroll
out = newLogger(false, true, true, colorFlag)
quietFlag bool
verboseFlag bool
proxyFlag string
localeFlag string
colorFlag bool
)
var rootCmd = &cobra.Command{
Use: "crunchyroll",
Short: "Download crunchyroll videos with ease",
PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
if verboseFlag {
out = newLogger(true, true, true, colorFlag)
} else if quietFlag {
out = newLogger(false, false, false, false)
}
out.DebugLog.Printf("Executing `%s` command with %d arg(s)\n", cmd.Name(), len(args))
locale = localeToLOCALE(localeFlag)
client, err = createOrDefaultClient(proxyFlag)
return
},
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Disable all output")
rootCmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "Adds debug messages to the normal output")
rootCmd.PersistentFlags().StringVarP(&proxyFlag, "proxy", "p", "", "Proxy to use")
rootCmd.PersistentFlags().StringVarP(&localeFlag, "locale", "l", systemLocale(), "The locale to use")
rootCmd.PersistentFlags().BoolVar(&colorFlag, "color", false, "Colored output. Only available on not windows systems")
}
func Execute() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
defer func() {
if r := recover(); r != nil {
out.Errln(r)
// change color to red
if colorFlag && runtime.GOOS != "windows" {
out.ErrLog.SetOutput(&loggerWriter{original: out.ErrLog.Writer(), color: "\033[31m"})
}
out.Debugln(string(debug.Stack()))
os.Exit(2)
}
}()
if err := rootCmd.Execute(); err != nil {
out.Fatalln(err)
}
}

View file

@ -0,0 +1,301 @@
package cmd
import (
"fmt"
"github.com/ByteDream/crunchyroll"
"github.com/ByteDream/crunchyroll/utils"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
var sessionIDPath = path.Join(os.TempDir(), ".crunchy")
type progress struct {
status bool
message string
}
type logger struct {
DebugLog *log.Logger
InfoLog *log.Logger
ErrLog *log.Logger
devView bool
progressWG sync.Mutex
progress chan progress
}
func newLogger(debug, info, err bool, color bool) *logger {
debugLog, infoLog, errLog := log.New(io.Discard, "=> ", 0), log.New(io.Discard, "=> ", 0), log.New(io.Discard, "=> ", 0)
debugColor, infoColor, errColor := "", "", ""
if color && runtime.GOOS != "windows" {
debugColor, infoColor, errColor = "\033[95m", "\033[96m", "\033[31m"
}
if debug {
debugLog.SetOutput(&loggerWriter{original: os.Stdout, color: debugColor})
}
if info {
infoLog.SetOutput(&loggerWriter{original: os.Stdout, color: infoColor})
}
if err {
errLog.SetOutput(&loggerWriter{original: os.Stdout, color: errColor})
}
if debug {
debugLog = log.New(debugLog.Writer(), "[debug] ", 0)
infoLog = log.New(infoLog.Writer(), "[info] ", 0)
errLog = log.New(errLog.Writer(), "[err] ", 0)
}
return &logger{
DebugLog: debugLog,
InfoLog: infoLog,
ErrLog: errLog,
devView: debug,
}
}
func (l *logger) Empty() {
if !l.devView && l.InfoLog.Writer() != io.Discard {
fmt.Println()
}
}
func (l *logger) StartProgress(message string) {
if l.devView {
l.InfoLog.Println(message)
return
}
l.progress = make(chan progress)
go func() {
states := []string{"-", "\\", "|", "/"}
for i := 0; ; i++ {
l.progressWG.Lock()
select {
case p := <-l.progress:
fmt.Print("\r")
if p.status {
l.InfoLog.Printf("✅ %s", p.message)
} else {
l.ErrLog.Printf("❌ %s", p.message)
}
l.progress = nil
l.progressWG.Unlock()
return
default:
if i%10 == 0 {
fmt.Printf("\r%s%s %s", l.InfoLog.Prefix(), states[i/10%4], message)
}
time.Sleep(35 * time.Millisecond)
l.progressWG.Unlock()
}
}
}()
}
func (l *logger) StartProgressf(message string, a ...interface{}) {
l.StartProgress(fmt.Sprintf(message, a...))
}
func (l *logger) EndProgress(successful bool, message string) {
if l.devView {
if successful {
l.InfoLog.Print(message)
} else {
l.ErrLog.Print(message)
}
return
}
l.progress <- progress{
status: successful,
message: message,
}
}
func (l *logger) EndProgressf(successful bool, message string, a ...interface{}) {
l.EndProgress(successful, fmt.Sprintf(message, a...))
}
func (l *logger) Debugln(v ...interface{}) {
l.print(0, v...)
}
func (l *logger) Debugf(message string, a ...interface{}) {
l.print(0, fmt.Sprintf(message, a...))
}
func (l *logger) Infoln(v ...interface{}) {
l.print(1, v...)
}
func (l *logger) Infof(message string, a ...interface{}) {
l.print(1, fmt.Sprintf(message, a...))
}
func (l *logger) Errln(v ...interface{}) {
l.print(2, v...)
}
func (l *logger) Errf(message string, a ...interface{}) {
l.print(2, fmt.Sprintf(message, a...))
}
func (l *logger) Fatalln(v ...interface{}) {
l.print(2, v...)
os.Exit(1)
}
func (l *logger) Fatalf(message string, a ...interface{}) {
l.print(2, fmt.Sprintf(message, a...))
os.Exit(1)
}
func (l *logger) print(level int, v ...interface{}) {
if l.progress != nil {
l.progressWG.Lock()
defer l.progressWG.Unlock()
fmt.Print("\r")
}
switch level {
case 0:
l.DebugLog.Print(v...)
case 1:
l.InfoLog.Print(v...)
case 2:
l.ErrLog.Print(v...)
}
}
type loggerWriter struct {
io.Writer
original io.Writer
color string
}
func (lw *loggerWriter) Write(p []byte) (n int, err error) {
if lw.color != "" {
p = append([]byte(lw.color), p...)
p = append(p, []byte("\033[0m")...)
}
return lw.original.Write(p)
}
// systemLocale receives the system locale
// https://stackoverflow.com/questions/51829386/golang-get-system-language/51831590#51831590
func systemLocale() string {
if runtime.GOOS != "windows" {
if lang, ok := os.LookupEnv("LANG"); ok {
return strings.ReplaceAll(strings.Split(lang, ".")[0], "_", "-")
}
} else {
cmd := exec.Command("powershell", "Get-Culture | select -exp Name")
if output, err := cmd.Output(); err != nil {
return strings.Trim(string(output), "\r\n")
}
}
return "en-US"
}
func localeToLOCALE(locale string) crunchyroll.LOCALE {
if l := crunchyroll.LOCALE(locale); utils.ValidateLocale(l) {
return l
} else {
out.Errf("%s is not a supported locale, using %s as fallback\n", locale, crunchyroll.US)
return crunchyroll.US
}
}
func allLocalesAsStrings() (locales []string) {
for _, locale := range utils.AllLocales {
locales = append(locales, string(locale))
}
return
}
func createOrDefaultClient(proxy string) (*http.Client, error) {
if proxy == "" {
return http.DefaultClient, nil
} else {
out.Infof("Using custom proxy %s\n", proxy)
proxyURL, err := url.Parse(proxy)
if err != nil {
return nil, err
}
client := &http.Client{
Transport: &http.Transport{
DisableCompression: true,
Proxy: http.ProxyURL(proxyURL),
},
Timeout: 30 * time.Second,
}
return client, nil
}
}
func loadSessionID() (string, error) {
if _, stat := os.Stat(sessionIDPath); os.IsNotExist(stat) {
out.Fatalf("To use this command, login first. Type `%s login -h` to get help\n", os.Args[0])
}
body, err := ioutil.ReadFile(sessionIDPath)
if err != nil {
return "", err
}
return strings.ReplaceAll(string(body), "\n", ""), nil
}
func loadCrunchy() {
out.StartProgress("Logging in")
sessionID, err := loadSessionID()
if err == nil {
if crunchy, err = crunchyroll.LoginWithSessionID(sessionID, locale, client); err != nil {
out.EndProgress(false, err.Error())
os.Exit(1)
}
} else {
out.EndProgress(false, err.Error())
os.Exit(1)
}
out.EndProgress(true, "Logged in")
out.Debugf("Logged in with session id %s\n", sessionID)
}
func hasFFmpeg() bool {
cmd := exec.Command("ffmpeg", "-h")
return cmd.Run() == nil
}
func terminalWidth() int {
if runtime.GOOS != "windows" {
cmd := exec.Command("stty", "size")
cmd.Stdin = os.Stdin
out, err := cmd.Output()
if err != nil {
return 60
}
width, err := strconv.Atoi(strings.Split(strings.ReplaceAll(string(out), "\n", ""), " ")[1])
if err != nil {
return 60
}
return width
}
return 60
}

11
cmd/crunchyroll/main.go Normal file
View file

@ -0,0 +1,11 @@
package main
// the cli will be redesigned soon
import (
"github.com/ByteDream/crunchyroll/cmd/crunchyroll/cmd"
)
func main() {
cmd.Execute()
}

347
crunchyroll.go Normal file
View file

@ -0,0 +1,347 @@
package crunchyroll
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
)
// LOCALE represents a locale / language
type LOCALE string
const (
JP LOCALE = "ja-JP"
US = "en-US"
LA = "es-LA"
ES = "es-ES"
FR = "fr-FR"
BR = "pt-BR"
IT = "it-IT"
DE = "de-DE"
RU = "ru-RU"
ME = "ar-ME"
)
type Crunchyroll struct {
// Client is the http.Client to perform all requests over
Client *http.Client
// Locale specifies in which language all results should be returned / requested
Locale LOCALE
// SessionID is the crunchyroll session id which was used for authentication
SessionID string
// Config stores parameters which are needed by some api calls
Config struct {
TokenType string
AccessToken string
CountryCode string
Premium bool
Channel string
Policy string
Signature string
KeyPairID string
AccountID string
ExternalID string
MaturityRating string
}
}
// LoginWithCredentials logs in via crunchyroll email and password
func LoginWithCredentials(email string, password string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
sessionIDEndpoint := fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?version=1.0&access_token=%s&device_type=%s&device_id=%s",
"LNDJgOit5yaRIWN", "com.crunchyroll.windows.desktop", "Az2srGnChW65fuxYz2Xxl1GcZQgtGgI")
sessResp, err := client.Get(sessionIDEndpoint)
if err != nil {
return nil, err
}
defer sessResp.Body.Close()
var data map[string]interface{}
body, _ := ioutil.ReadAll(sessResp.Body)
json.Unmarshal(body, &data)
sessionID := data["data"].(map[string]interface{})["session_id"].(string)
loginEndpoint := "https://api.crunchyroll.com/login.0.json"
authValues := url.Values{}
authValues.Set("session_id", sessionID)
authValues.Set("account", email)
authValues.Set("password", password)
client.Post(loginEndpoint, "application/x-www-form-urlencoded", bytes.NewBufferString(authValues.Encode()))
return LoginWithSessionID(sessionID, locale, client)
}
// LoginWithSessionID logs in via a crunchyroll session id.
// Session ids are automatically generated as a cookie when visiting https://www.crunchyroll.com
func LoginWithSessionID(sessionID string, locale LOCALE, client *http.Client) (*Crunchyroll, error) {
crunchy := &Crunchyroll{
Client: client,
Locale: locale,
SessionID: sessionID,
}
var endpoint string
var err error
var resp *http.Response
var jsonBody map[string]interface{}
// start session
endpoint = fmt.Sprintf("https://api.crunchyroll.com/start_session.0.json?session_id=%s",
sessionID)
resp, err = client.Get(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
if _, ok := jsonBody["message"]; ok {
return nil, errors.New("invalid session id")
}
data := jsonBody["data"].(map[string]interface{})
crunchy.Config.CountryCode = data["country_code"].(string)
user := data["user"]
if user == nil {
return nil, errors.New("invalid session id, user is not logged in")
}
if user.(map[string]interface{})["premium"] == "" {
crunchy.Config.Premium = false
crunchy.Config.Channel = "-"
} else {
crunchy.Config.Premium = true
crunchy.Config.Channel = "crunchyroll"
}
var etpRt string
for _, cookie := range resp.Cookies() {
if cookie.Name == "etp_rt" {
etpRt = cookie.Value
break
}
}
// token
endpoint = "https://beta-api.crunchyroll.com/auth/v1/token"
grantType := url.Values{}
grantType.Set("grant_type", "etp_rt_cookie")
authRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(grantType.Encode()))
if err != nil {
return nil, err
}
authRequest.Header.Add("Authorization", "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6")
authRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded")
authRequest.AddCookie(&http.Cookie{
Name: "session_id",
Value: sessionID,
})
authRequest.AddCookie(&http.Cookie{
Name: "etp_rt",
Value: etpRt,
})
resp, err = client.Do(authRequest)
if err != nil {
return nil, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
crunchy.Config.TokenType = jsonBody["token_type"].(string)
crunchy.Config.AccessToken = jsonBody["access_token"].(string)
// index
endpoint = "https://beta-api.crunchyroll.com/index/v2"
resp, err = crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
cms := jsonBody["cms"].(map[string]interface{})
crunchy.Config.Policy = cms["policy"].(string)
crunchy.Config.Signature = cms["signature"].(string)
crunchy.Config.KeyPairID = cms["key_pair_id"].(string)
// me
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me"
resp, err = crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
crunchy.Config.AccountID = jsonBody["account_id"].(string)
crunchy.Config.ExternalID = jsonBody["external_id"].(string)
//profile
endpoint = "https://beta-api.crunchyroll.com/accounts/v1/me/profile"
resp, err = crunchy.request(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&jsonBody)
crunchy.Config.MaturityRating = jsonBody["maturity_rating"].(string)
return crunchy, nil
}
// request is a base function which handles api requests
func (c *Crunchyroll) request(endpoint string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", fmt.Sprintf("%s %s", c.Config.TokenType, c.Config.AccessToken))
resp, err := c.Client.Do(req)
if err == nil {
bodyAsBytes, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, &AccessError{
URL: endpoint,
Body: bodyAsBytes,
}
} else {
var errStruct struct {
Message string `json:"message"`
}
json.NewDecoder(bytes.NewBuffer(bodyAsBytes)).Decode(&errStruct)
if errStruct.Message != "" {
return nil, &AccessError{
URL: endpoint,
Body: bodyAsBytes,
Message: errStruct.Message,
}
}
}
resp.Body = ioutil.NopCloser(bytes.NewBuffer(bodyAsBytes))
}
return resp, err
}
// 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)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
var jsonBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&jsonBody)
for _, item := range jsonBody["items"].([]interface{}) {
item := item.(map[string]interface{})
if item["total"].(float64) > 0 {
switch item["type"] {
case "series":
for _, series := range item["items"].([]interface{}) {
series2 := &Series{
crunchy: c,
}
if err := decodeMapToStruct(series, series2); err != nil {
return nil, nil, err
}
if err := decodeMapToStruct(series.(map[string]interface{})["series_metadata"].(map[string]interface{}), series2); err != nil {
return nil, nil, err
}
s = append(s, series2)
}
case "movie_listing":
for _, movie := range item["items"].([]interface{}) {
movie2 := &Movie{
crunchy: c,
}
if err := decodeMapToStruct(movie, movie2); err != nil {
return nil, nil, err
}
m = append(m, movie2)
}
}
}
}
return s, m, nil
}
// FindVideo fins a Video (Season or Movie) by a crunchyroll link
// e.g. https://www.crunchyroll.com/darling-in-the-franxx
func (c *Crunchyroll) FindVideo(seriesUrl string) (Video, error) {
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)/?$`)
if urlMatch := pattern.FindAllStringSubmatch(seriesUrl, -1); len(urlMatch) != 0 {
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
title, ok := groups["series"]
if !ok {
return nil, errors.New("series could not be found")
}
s, m, err := c.Search(title, 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, errors.New("no series or movie could be found")
}
return nil, errors.New("invalid url")
}
// FindEpisode finds an episode by its crunchyroll link
// e.g. https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575
func (c *Crunchyroll) FindEpisode(url string) ([]*Episode, error) {
pattern := regexp.MustCompile(`(?m)^https?://(www\.)?crunchyroll\.com(/\w{2}(-\w{2})?)?/(?P<series>[^/]+)/episode-\d+-(?P<title>\D+).*`)
if urlMatch := pattern.FindAllStringSubmatch(url, -1); len(urlMatch) != 0 {
groups := regexGroups(urlMatch, pattern.SubexpNames()...)
var slugTitle string
var ok bool
if slugTitle, ok = groups["title"]; !ok {
return nil, errors.New("invalid url")
}
slugTitle = strings.TrimSuffix(slugTitle, "-")
video, err := c.FindVideo(fmt.Sprintf("https://www.crunchyroll.com/%s", groups["series"]))
if err != nil {
return nil, err
}
seasons, err := video.(*Series).Seasons()
if err != nil {
return nil, err
}
var matchingEpisodes []*Episode
for _, season := range seasons {
episodes, err := season.Episodes()
if err != nil {
return nil, err
}
for _, episode := range episodes {
if episode.SlugTitle == slugTitle {
matchingEpisodes = append(matchingEpisodes, episode)
}
}
}
return matchingEpisodes, nil
}
return nil, errors.New("invalid url")
}

115
crunchyroll_test.go Normal file
View file

@ -0,0 +1,115 @@
package crunchyroll
import (
"github.com/grafov/m3u8"
"net/http"
"os"
"testing"
)
var (
email = os.Getenv("EMAIL")
password = os.Getenv("PASSWORD")
sessionID = os.Getenv("SESSION_ID")
crunchy *Crunchyroll
season *Season
episode *Episode
stream *Stream
)
func TestLogin(t *testing.T) {
var err error
if email != "" && password != "" {
crunchy, err = LoginWithCredentials(email, password, DE, http.DefaultClient)
if err != nil {
t.Error(err)
}
t.Logf("Logged in with email and password\nAuth: %s %s\nSession id: %s",
crunchy.Config.TokenType, crunchy.Config.AccessToken, crunchy.SessionID)
} else if sessionID != "" {
crunchy, err = LoginWithSessionID(sessionID, DE, http.DefaultClient)
if err != nil {
t.Error(err)
}
t.Logf("Logged in with session id\nAuth: %s %s\nSession id: %s",
crunchy.Config.TokenType, crunchy.Config.AccessToken, crunchy.SessionID)
} else {
t.Skipf("email and / or password and session id environtment variables are not set, skipping login. All following test may fail also")
}
}
func TestCrunchy_Search(t *testing.T) {
series, movies, err := crunchy.Search("movie", 20)
if err != nil {
t.Error(err)
}
t.Logf("Found %d series and %d movie(s) for search query `movie`", len(series), len(movies))
}
func TestSeries_Seasons(t *testing.T) {
video, err := crunchy.FindVideo("https://www.crunchyroll.com/darling-in-the-franxx")
if err != nil {
t.Error(err)
}
series := video.(*Series)
seasons, err := series.Seasons()
if err != nil {
t.Error(err)
}
if len(seasons) > 0 {
season = seasons[4]
} else {
t.Logf("%s has no seasons, some future test will fail", series.Title)
}
t.Logf("Found %d seasons for series %s", len(seasons), series.Title)
}
func TestCrunchyroll_FindEpisode(t *testing.T) {
episodes, err := crunchy.FindEpisode("https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575")
if err != nil {
t.Error(err)
}
t.Logf("Found %d episodes for episode %s", len(episodes), "https://www.crunchyroll.com/darling-in-the-franxx/episode-1-alone-and-lonesome-759575")
}
func TestSeason_Episodes(t *testing.T) {
episodes, err := season.Episodes()
if err != nil {
t.Error(err)
}
if len(episodes) > 0 {
episode = episodes[0]
} else {
t.Logf("%s has no episodes, some future test will fail", season.Title)
}
t.Logf("Found %d episodes for season %s", len(episodes), season.Title)
}
func TestEpisode_Streams(t *testing.T) {
streams, err := episode.Streams()
if err != nil {
t.Error(err)
}
if len(streams) > 0 {
stream = streams[0]
} else {
t.Logf("%s has no streams, some future test will fail", season.Title)
}
t.Logf("Found %d streams for episode %s", len(streams), season.Title)
}
func TestFormat_Download(t *testing.T) {
formats, err := stream.Formats()
if err != nil {
t.Error(err)
}
file, err := os.Create("test")
if err != nil {
t.Error(err)
}
formats[0].Download(file, func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error {
t.Logf("Downloaded %.2f%% (%d/%d)", float32(current)/float32(total)*100, current, total)
return nil
})
}

138
episode.go Normal file
View file

@ -0,0 +1,138 @@
package crunchyroll
import (
"encoding/json"
"fmt"
"regexp"
"time"
)
type Episode struct {
crunchy *Crunchyroll
siteCache map[string]interface{}
ID string `json:"id"`
SeriesID string `json:"series_id"`
SeriesTitle string `json:"series_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"`
IsMature bool `json:"is_mature"`
MatureBlocked bool `json:"mature_blocked"`
EpisodeAirDate time.Time `json:"episode_air_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"`
Slug string `json:"slug"`
Images struct {
Thumbnail [][]struct {
Width int `json:"width"`
Height int `json:"height"`
Type string `json:"type"`
Source string `json:"source"`
} `json:"thumbnail"`
} `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
}
// EpisodeFromID returns an episode by its api id
func EpisodeFromID(crunchy *Crunchyroll, id string) (*Episode, error) {
resp, err := crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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)
episode := &Episode{
crunchy: crunchy,
}
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
}
// 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.
// Otherwise, this function will cause massive heap on a season which many episodes
func (e *Episode) AudioLocale() (LOCALE, error) {
resp, err := e.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
e.crunchy.Config.CountryCode,
e.crunchy.Config.MaturityRating,
e.crunchy.Config.Channel,
e.StreamID,
e.crunchy.Locale,
e.crunchy.Config.Signature,
e.crunchy.Config.Policy,
e.crunchy.Config.KeyPairID))
if err != nil {
return "", err
}
defer resp.Body.Close()
var jsonBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&jsonBody)
e.siteCache = jsonBody
return LOCALE(jsonBody["audio_locale"].(string)), nil
}
// Streams returns all streams which are available for the episode
func (e *Episode) Streams() ([]*Stream, error) {
return fromVideoStreams(e.crunchy, fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
e.crunchy.Config.CountryCode,
e.crunchy.Config.MaturityRating,
e.crunchy.Config.Channel,
e.StreamID,
e.crunchy.Locale,
e.crunchy.Config.Signature,
e.crunchy.Config.Policy,
e.crunchy.Config.KeyPairID))
}

21
error.go Normal file
View file

@ -0,0 +1,21 @@
package crunchyroll
import "fmt"
// AccessError is an error which will be returned when some special sort of api request fails.
// See Crunchyroll.request when the error gets used
type AccessError struct {
error
URL string
Body []byte
Message string
}
func (ae *AccessError) Error() string {
if ae.Message == "" {
return fmt.Sprintf("Access token invalid for url %s\nBody: %s", ae.URL, string(ae.Body))
} else {
return ae.Message
}
}

224
format.go Normal file
View file

@ -0,0 +1,224 @@
package crunchyroll
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"fmt"
"github.com/grafov/m3u8"
"io/ioutil"
"net/http"
"os"
"path"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
)
const (
EPISODE FormatType = "episodes"
MOVIE = "movies"
)
type FormatType string
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
}
// Download downloads the format to the given output file (as .ts file).
// See Format.DownloadSegments for more information
func (f *Format) Download(output *os.File, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error) error {
downloadDir, err := os.MkdirTemp("", "crunchy_")
if err != nil {
return err
}
defer os.RemoveAll(downloadDir)
if err := f.DownloadSegments(downloadDir, 4, onSegmentDownload); err != nil {
return err
}
return f.mergeSegments(downloadDir, output)
}
// 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 (f *Format) DownloadSegments(outputDir string, goroutines int, onSegmentDownload func(segment *m3u8.MediaSegment, current, total int, file *os.File, err error) error) error {
resp, err := f.crunchy.Client.Get(f.Video.URI)
if err != nil {
return err
}
defer resp.Body.Close()
// reads the m3u8 file
playlist, _, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return err
}
// extracts the segments from the playlist
var segments []*m3u8.MediaSegment
for _, segment := range playlist.(*m3u8.MediaPlaylist).Segments {
// some segments are nil, so they have to be filtered out
if segment != nil {
segments = append(segments, segment)
}
}
var wg sync.WaitGroup
chunkSize := len(segments) / goroutines
// when a afterDownload call returns an error, this channel will be set to true and stop all goroutines
quit := make(chan bool)
// 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 := f.getCrypt(segments[0])
if err != nil {
return err
}
var current int32
for i := 0; i < len(segments); i += chunkSize {
wg.Add(1)
end := i + chunkSize
if end > len(segments) {
end = len(segments)
}
i := i
go func() {
for j, segment := range segments[i:end] {
select {
case <-quit:
break
default:
var file *os.File
file, err = f.downloadSegment(segment, path.Join(outputDir, fmt.Sprintf("%d.ts", i+j)), block, iv)
if err != nil {
quit <- true
break
}
if onSegmentDownload != nil {
if err = onSegmentDownload(segment, int(atomic.AddInt32(&current, 1)), len(segments), file, err); err != nil {
quit <- true
file.Close()
break
}
}
file.Close()
}
}
wg.Done()
}()
}
wg.Wait()
select {
case <-quit:
return err
default:
return nil
}
}
// getCrypt extracts the key and iv of a m3u8 segment and converts it into a cipher.Block block and a iv byte sequence
func (f *Format) getCrypt(segment *m3u8.MediaSegment) (block cipher.Block, iv []byte, err error) {
var resp *http.Response
resp, err = f.crunchy.Client.Get(segment.Key.URI)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
key, err := ioutil.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 segments, decrypts it and names it after the given index
func (f *Format) downloadSegment(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 := decryptSegment(f.crunchy.Client, segment, block, iv)
if err != nil {
return nil, err
}
// some mpeg stream things. see the link beneath for more information
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/dl/dowloader.go#L135
syncByte := uint8(71) //0x47
for k := 0; k < len(content); k++ {
if content[k] == syncByte {
content = content[k:]
break
}
}
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
}
// mergeSegments reads every file in tempPath and write their content to output
func (f *Format) mergeSegments(tempPath string, output *os.File) error {
dir, err := os.ReadDir(tempPath)
if err != nil {
return err
}
writer := bufio.NewWriter(output)
defer writer.Flush()
// sort the directory files after their numeric names
sort.Slice(dir, func(i, j int) bool {
iNum, err := strconv.Atoi(strings.Split(dir[i].Name(), ".")[0])
if err != nil {
return false
}
jNum, err := strconv.Atoi(strings.Split(dir[j].Name(), ".")[0])
if err != nil {
return false
}
return iNum < jNum
})
for _, file := range dir {
bodyAsBytes, err := ioutil.ReadFile(path.Join(tempPath, file.Name()))
if err != nil {
return err
}
if _, err = writer.Write(bodyAsBytes); err != nil {
return err
}
}
return nil
}

8
go.mod Normal file
View file

@ -0,0 +1,8 @@
module github.com/ByteDream/crunchyroll
go 1.16
require (
github.com/grafov/m3u8 v0.11.1
github.com/spf13/cobra v1.2.1
)

99
movie_listing.go Normal file
View file

@ -0,0 +1,99 @@
package crunchyroll
import (
"encoding/json"
"fmt"
)
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 [][]struct {
Width int `json:"width"`
Height int `json:"height"`
Type string `json:"type"`
Source string `json:"source"`
} `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/%s/%s/movie_listing/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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)
movieListing := &MovieListing{
crunchy: crunchy,
}
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/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
ml.crunchy.Config.CountryCode,
ml.crunchy.Config.MaturityRating,
ml.crunchy.Config.Channel,
ml.ID,
ml.crunchy.Locale,
ml.crunchy.Config.Signature,
ml.crunchy.Config.Policy,
ml.crunchy.Config.KeyPairID))
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/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
ml.crunchy.Config.CountryCode,
ml.crunchy.Config.MaturityRating,
ml.crunchy.Config.Channel,
ml.ID,
ml.crunchy.Locale,
ml.crunchy.Config.Signature,
ml.crunchy.Config.Policy,
ml.crunchy.Config.KeyPairID))
}

95
season.go Normal file
View file

@ -0,0 +1,95 @@
package crunchyroll
import (
"encoding/json"
"fmt"
"regexp"
)
type Season struct {
crunchy *Crunchyroll
ID string `json:"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"`
Language 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/%s/%s/seasons/%s?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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,
}
if err := decodeMapToStruct(jsonBody, season); err != nil {
return nil, err
}
return season, nil
}
// Episodes returns all episodes which are available for the season
func (s *Season) Episodes() (episodes []*Episode, err error) {
resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/episodes?season_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
s.crunchy.Config.CountryCode,
s.crunchy.Config.MaturityRating,
s.crunchy.Config.Channel,
s.ID,
s.crunchy.Locale,
s.crunchy.Config.Signature,
s.crunchy.Config.Policy,
s.crunchy.Config.KeyPairID))
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)^/cms/v2/\S+videos/(\w+)/streams$`).FindAllStringSubmatch(streamHref, -1); len(match) > 0 {
episode.StreamID = match[0][1]
}
}
episodes = append(episodes, episode)
}
return
}

116
stream.go Normal file
View file

@ -0,0 +1,116 @@
package crunchyroll
import (
"encoding/json"
"errors"
"fmt"
"github.com/grafov/m3u8"
"regexp"
)
type Stream struct {
crunchy *Crunchyroll
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/%s/%s/videos/%s/streams?locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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) {
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,
})
}
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)
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 has just a normal account and not one with premium
return nil, errors.New("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)^/cms/v2/\S+/crunchyroll/(\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
}

25
subtitle.go Normal file
View file

@ -0,0 +1,25 @@
package crunchyroll
import (
"io"
"os"
)
type Subtitle struct {
crunchy *Crunchyroll
Locale LOCALE `json:"locale"`
URL string `json:"url"`
Format string `json:"format"`
}
func (s Subtitle) Download(file *os.File) error {
resp, err := s.crunchy.Client.Get(s.URL)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.Copy(file, resp.Body)
return err
}

57
utils.go Normal file
View file

@ -0,0 +1,57 @@
package crunchyroll
import (
"crypto/cipher"
"encoding/json"
"github.com/grafov/m3u8"
"io/ioutil"
"net/http"
)
func decodeMapToStruct(m interface{}, s interface{}) error {
jsonBody, err := json.Marshal(m)
if err != nil {
return err
}
return json.Unmarshal(jsonBody, s)
}
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L25
func decryptSegment(client *http.Client, segment *m3u8.MediaSegment, block cipher.Block, iv []byte) ([]byte, error) {
resp, err := client.Get(segment.URI)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := ioutil.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 = pkcs5UnPadding(decrypted)
return raw, nil
}
// https://github.com/oopsguy/m3u8/blob/4150e93ec8f4f8718875a02973f5d792648ecb97/tool/crypt.go#L47
func pkcs5UnPadding(origData []byte) []byte {
length := len(origData)
unPadding := int(origData[length-1])
return origData[:(length - unPadding)]
}
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
}

70
utils/locale.go Normal file
View file

@ -0,0 +1,70 @@
package utils
import (
"github.com/ByteDream/crunchyroll"
)
var AllLocales = []crunchyroll.LOCALE{
crunchyroll.JP,
crunchyroll.US,
crunchyroll.LA,
crunchyroll.ES,
crunchyroll.FR,
crunchyroll.BR,
crunchyroll.IT,
crunchyroll.DE,
crunchyroll.RU,
crunchyroll.ME,
}
// 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.BR:
return "Portuguese (Brazil)"
case crunchyroll.IT:
return "Italian"
case crunchyroll.DE:
return "German"
case crunchyroll.RU:
return "Russian"
case crunchyroll.ME:
return "Arabic"
default:
return ""
}
}
// SubtitleByLocale returns the subtitle of a crunchyroll.Format by its locale.
// Check the second ok return value if the format has this subtitle
func SubtitleByLocale(format *crunchyroll.Format, locale crunchyroll.LOCALE) (subtitle *crunchyroll.Subtitle, ok bool) {
if format.Subtitles == nil {
return
}
for _, sub := range format.Subtitles {
if sub.Locale == locale {
return sub, true
}
}
return
}

54
utils/sort.go Normal file
View file

@ -0,0 +1,54 @@
package utils
import (
"github.com/ByteDream/crunchyroll"
"strconv"
"strings"
)
// 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 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
}
// FormatsByResolution sort 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.Split(fbr[i].Video.Resolution, "x")
iResX, _ := strconv.Atoi(iSplitRes[0])
iResY, _ := strconv.Atoi(iSplitRes[1])
jSplitRes := strings.Split(fbr[j].Video.Resolution, "x")
jResX, _ := strconv.Atoi(jSplitRes[0])
jResY, _ := strconv.Atoi(jSplitRes[1])
return iResX+iResY < jResX+jResY
}

626
utils/structure.go Normal file
View file

@ -0,0 +1,626 @@
package utils
import (
"errors"
"github.com/ByteDream/crunchyroll"
"sort"
"sync"
)
// FormatStructure is the basic structure which every other structure implements.
// With it, and all other structures the api usage can be simplified magnificent
type FormatStructure struct {
// initState is true if every format, stream, ... in the structure tree is initialized
initState bool
// getFunc specified the function which will be called if crunchyroll.Format is empty / not initialized yet.
// It returns the formats itself, the parent streams (might be nil) and an error if one occurs
getFunc func() ([]*crunchyroll.Format, []*crunchyroll.Stream, error)
// formats holds all formats which were given
formats []*crunchyroll.Format
// parents holds all parents which were given
parents []*crunchyroll.Stream
}
func newFormatStructure(parentStructure *StreamStructure) *FormatStructure {
return &FormatStructure{
getFunc: func() (formats []*crunchyroll.Format, parents []*crunchyroll.Stream, err error) {
streams, err := parentStructure.Streams()
if err != nil {
return
}
var wg sync.WaitGroup
var lock sync.Mutex
for _, stream := range streams {
wg.Add(1)
stream := stream
go func() {
defer wg.Done()
f, err := stream.Formats()
if err != nil {
return
}
lock.Lock()
defer lock.Unlock()
for _, format := range f {
formats = append(formats, format)
parents = append(parents, stream)
}
}()
}
wg.Wait()
return
},
}
}
// NewFormatStructure returns a new FormatStructure, based on the given formats
func NewFormatStructure(formats []*crunchyroll.Format) *FormatStructure {
return &FormatStructure{
getFunc: func() ([]*crunchyroll.Format, []*crunchyroll.Stream, error) {
return formats, nil, nil
},
}
}
// Formats returns all stored formats
func (fs *FormatStructure) Formats() ([]*crunchyroll.Format, error) {
var err error
if fs.formats == nil {
if fs.formats, fs.parents, err = fs.getFunc(); err != nil {
return nil, err
}
fs.initState = true
}
return fs.formats, nil
}
// FormatParent returns the parent stream of a format (if present).
// If the format or parent is not stored, an error will be returned
func (fs *FormatStructure) FormatParent(format *crunchyroll.Format) (*crunchyroll.Stream, error) {
formats, err := fs.Formats()
if err != nil {
return nil, err
}
if fs.parents == nil {
return nil, errors.New("no parents are given")
}
for i, f := range formats {
if f == format {
return fs.parents[i], nil
}
}
return nil, errors.New("given format could not be found")
}
// InitAll recursive requests all given information.
// All functions of FormatStructure or other structs in this file which are executed after this have a much lesser chance to return any error,
// so the error return value of these functions can be pretty safely ignored.
// This function should only be called if you need to the access to any function of FormatStructure which returns a crunchyroll.Format (or an array of it).
// Re-calling this method can lead to heavy problems (believe me, it caused a simple bug and i've tried to fix it for several hours).
// Check FormatStructure.InitAllState if you can call this method without causing bugs
func (fs *FormatStructure) InitAll() error {
var err error
if fs.formats, fs.parents, err = fs.getFunc(); err != nil {
return err
}
fs.initState = true
return nil
}
// InitAllState returns FormatStructure.InitAll or FormatStructure.Formats was called.
// If so, all errors which are returned by functions of structs in this file can be safely ignored
func (fs *FormatStructure) InitAllState() bool {
return fs.initState
}
// AvailableLocales returns all available audio, subtitle and hardsub locales for all formats.
// If includeEmpty is given, locales with no value are included too
func (fs *FormatStructure) AvailableLocales(includeEmpty bool) (audioLocales []crunchyroll.LOCALE, subtitleLocales []crunchyroll.LOCALE, hardsubLocales []crunchyroll.LOCALE, err error) {
var formats []*crunchyroll.Format
if formats, err = fs.Formats(); err != nil {
return
}
audioMap := map[crunchyroll.LOCALE]interface{}{}
subtitleMap := map[crunchyroll.LOCALE]interface{}{}
hardsubMap := map[crunchyroll.LOCALE]interface{}{}
for _, format := range formats {
// audio locale should always have a valid locale
if includeEmpty || !includeEmpty && format.AudioLocale != "" {
audioMap[format.AudioLocale] = nil
}
if format.Subtitles != nil {
for _, subtitle := range format.Subtitles {
if subtitle.Locale == "" && !includeEmpty {
continue
}
subtitleMap[subtitle.Locale] = nil
}
}
if includeEmpty || !includeEmpty && format.Hardsub != "" {
hardsubMap[format.Hardsub] = nil
}
}
for k := range audioMap {
audioLocales = append(audioLocales, k)
}
for k := range subtitleMap {
subtitleLocales = append(subtitleLocales, k)
}
for k := range hardsubMap {
hardsubLocales = append(hardsubLocales, k)
}
return
}
// FilterFormatsByAudio returns all formats which have the given locale as their audio locale
func (fs *FormatStructure) FilterFormatsByAudio(locale crunchyroll.LOCALE) (f []*crunchyroll.Format, err error) {
var formats []*crunchyroll.Format
if formats, err = fs.Formats(); err != nil {
return nil, err
}
for _, format := range formats {
if format.AudioLocale == locale {
f = append(f, format)
}
}
return
}
// FilterFormatsBySubtitle returns all formats which have the given locale as their subtitle locale.
// Hardsub indicates if the subtitle should be shown on the video itself
func (fs *FormatStructure) FilterFormatsBySubtitle(locale crunchyroll.LOCALE, hardsub bool) (f []*crunchyroll.Format, err error) {
var formats []*crunchyroll.Format
if formats, err = fs.Formats(); err != nil {
return nil, err
}
for _, format := range formats {
if hardsub && format.Hardsub == locale {
f = append(f, format)
} else if !hardsub && format.Hardsub == "" {
f = append(f, format)
}
}
return
}
// FilterFormatsByLocales returns all formats which have the given locales as their property.
// Hardsub is the same as in FormatStructure.FilterFormatsBySubtitle
func (fs *FormatStructure) FilterFormatsByLocales(audioLocale, subtitleLocale crunchyroll.LOCALE, hardsub bool) ([]*crunchyroll.Format, error) {
var f []*crunchyroll.Format
formats, err := fs.Formats()
if err != nil {
return nil, err
}
for _, format := range formats {
if format.AudioLocale == audioLocale {
if hardsub && format.Hardsub == subtitleLocale {
f = append(f, format)
} else if !hardsub && format.Hardsub == "" {
f = append(f, format)
}
}
}
if len(f) == 0 {
return nil, errors.New("could not find any matching format")
}
return f, nil
}
// OrderFormatsByID loops through all stored formats and returns a 2d slice
// where a row represents an id and the column all formats which have this id
func (fs *FormatStructure) OrderFormatsByID() ([][]*crunchyroll.Format, error) {
formats, err := fs.Formats()
if err != nil {
return nil, err
}
formatsMap := map[string][]*crunchyroll.Format{}
for _, format := range formats {
if _, ok := formatsMap[format.ID]; !ok {
formatsMap[format.ID] = make([]*crunchyroll.Format, 0)
}
formatsMap[format.ID] = append(formatsMap[format.ID], format)
}
var orderedFormats [][]*crunchyroll.Format
for _, v := range formatsMap {
var f []*crunchyroll.Format
for _, format := range v {
f = append(f, format)
}
orderedFormats = append(orderedFormats, f)
}
return orderedFormats, nil
}
// StreamStructure fields are nearly same as FormatStructure
type StreamStructure struct {
*FormatStructure
getFunc func() ([]*crunchyroll.Stream, []crunchyroll.Video, error)
streams []*crunchyroll.Stream
parents []crunchyroll.Video
}
func newStreamStructure(structure VideoStructure) *StreamStructure {
var getFunc func() (streams []*crunchyroll.Stream, parents []crunchyroll.Video, err error)
switch structure.(type) {
case *EpisodeStructure:
episodeStructure := structure.(*EpisodeStructure)
getFunc = func() (streams []*crunchyroll.Stream, parents []crunchyroll.Video, err error) {
episodes, err := episodeStructure.Episodes()
if err != nil {
return
}
var wg sync.WaitGroup
var lock sync.Mutex
for _, episode := range episodes {
wg.Add(1)
episode := episode
go func() {
defer wg.Done()
s, err := episode.Streams()
if err != nil {
return
}
lock.Lock()
defer lock.Unlock()
for _, stream := range s {
streams = append(streams, stream)
parents = append(parents, episode)
}
}()
}
wg.Wait()
return
}
case *MovieListingStructure:
movieListingStructure := structure.(*MovieListingStructure)
getFunc = func() (streams []*crunchyroll.Stream, parents []crunchyroll.Video, err error) {
movieListings, err := movieListingStructure.MovieListings()
if err != nil {
return
}
var wg sync.WaitGroup
var lock sync.Mutex
for _, movieListing := range movieListings {
wg.Add(1)
movieListing := movieListing
go func() {
defer wg.Done()
s, err := movieListing.Streams()
if err != nil {
return
}
lock.Lock()
defer lock.Unlock()
for _, stream := range s {
streams = append(streams, stream)
parents = append(parents, movieListing)
}
}()
}
wg.Wait()
return
}
}
ss := &StreamStructure{
getFunc: getFunc,
}
ss.FormatStructure = newFormatStructure(ss)
return ss
}
// NewStreamStructure returns a new StreamStructure, based on the given formats
func NewStreamStructure(streams []*crunchyroll.Stream) *StreamStructure {
ss := &StreamStructure{
getFunc: func() ([]*crunchyroll.Stream, []crunchyroll.Video, error) {
return streams, nil, nil
},
}
ss.FormatStructure = newFormatStructure(ss)
return ss
}
// Streams returns all stored streams
func (ss *StreamStructure) Streams() ([]*crunchyroll.Stream, error) {
if ss.streams == nil {
var err error
if ss.streams, ss.parents, err = ss.getFunc(); err != nil {
return nil, err
}
}
return ss.streams, nil
}
// StreamParent returns the parent video (type crunchyroll.Series or crunchyroll.Movie) of a stream (if present).
// If the stream or parent is not stored, an error will be returned
func (ss *StreamStructure) StreamParent(stream *crunchyroll.Stream) (crunchyroll.Video, error) {
streams, err := ss.Streams()
if err != nil {
return nil, err
}
if ss.parents == nil {
return nil, errors.New("no parents are given")
}
for i, s := range streams {
if s == stream {
return ss.parents[i], nil
}
}
return nil, errors.New("given stream could not be found")
}
// VideoStructure is an interface which is implemented by EpisodeStructure and MovieListingStructure
type VideoStructure interface{}
// EpisodeStructure fields are nearly same as FormatStructure
type EpisodeStructure struct {
VideoStructure
*StreamStructure
getFunc func() ([]*crunchyroll.Episode, []*crunchyroll.Season, error)
episodes []*crunchyroll.Episode
parents []*crunchyroll.Season
}
func newEpisodeStructure(structure *SeasonStructure) *EpisodeStructure {
es := &EpisodeStructure{
getFunc: func() (episodes []*crunchyroll.Episode, parents []*crunchyroll.Season, err error) {
seasons, err := structure.Seasons()
if err != nil {
return
}
var wg sync.WaitGroup
var lock sync.Mutex
for _, season := range seasons {
wg.Add(1)
season := season
go func() {
defer wg.Done()
e, err := season.Episodes()
if err != nil {
return
}
lock.Lock()
defer lock.Unlock()
for _, episode := range e {
episodes = append(episodes, episode)
parents = append(parents, season)
}
}()
}
wg.Wait()
return
},
}
es.StreamStructure = newStreamStructure(es)
return es
}
// NewEpisodeStructure returns a new EpisodeStructure, based on the given formats
func NewEpisodeStructure(episodes []*crunchyroll.Episode) *EpisodeStructure {
es := &EpisodeStructure{
getFunc: func() ([]*crunchyroll.Episode, []*crunchyroll.Season, error) {
return episodes, nil, nil
},
}
es.StreamStructure = newStreamStructure(es)
return es
}
// Episodes returns all stored episodes
func (es *EpisodeStructure) Episodes() ([]*crunchyroll.Episode, error) {
if es.episodes == nil {
var err error
if es.episodes, es.parents, err = es.getFunc(); err != nil {
return nil, err
}
}
return es.episodes, nil
}
// EpisodeParent returns the parent season of a stream (if present).
// If the stream or parent is not stored, an error will be returned
func (es *EpisodeStructure) EpisodeParent(episode *crunchyroll.Episode) (*crunchyroll.Season, error) {
episodes, err := es.Episodes()
if err != nil {
return nil, err
}
if es.parents == nil {
return nil, errors.New("no parents are given")
}
for i, e := range episodes {
if e == episode {
return es.parents[i], nil
}
}
return nil, errors.New("given episode could not be found")
}
// GetEpisodeByFormat returns the episode to which the given format belongs to.
// If the format or the parent is not stored, an error will be returned
func (es *EpisodeStructure) GetEpisodeByFormat(format *crunchyroll.Format) (*crunchyroll.Episode, error) {
if !es.initState {
if err := es.InitAll(); err != nil {
return nil, err
}
}
formatParent, err := es.FormatParent(format)
if err != nil {
return nil, err
}
streamParent, err := es.StreamParent(formatParent)
if err != nil {
return nil, err
}
episode, ok := streamParent.(*crunchyroll.Episode)
if !ok {
return nil, errors.New("could not find parent episode")
}
return episode, nil
}
func (es *EpisodeStructure) OrderEpisodeByID() ([][]*crunchyroll.Episode, error) {
episodes, err := es.Episodes()
if err != nil {
return nil, err
}
episodesMap := map[string][]*crunchyroll.Episode{}
for _, episode := range episodes {
if _, ok := episodesMap[episode.ID]; !ok {
episodesMap[episode.ID] = make([]*crunchyroll.Episode, 0)
}
episodesMap[episode.ID] = append(episodesMap[episode.ID], episode)
}
var orderedEpisodes [][]*crunchyroll.Episode
for _, v := range episodesMap {
orderedEpisodes = append(orderedEpisodes, v)
}
return orderedEpisodes, nil
}
func (es *EpisodeStructure) OrderFormatsByEpisodeNumber() ([][]*crunchyroll.Format, error) {
formats, err := es.Formats()
if err != nil {
return nil, err
}
formatsMap := map[int][]*crunchyroll.Format{}
for _, format := range formats {
stream, err := es.FormatParent(format)
if err != nil {
return nil, err
}
video, err := es.StreamParent(stream)
if err != nil {
return nil, err
}
episode, ok := video.(*crunchyroll.Episode)
if !ok {
continue
}
if _, ok := formatsMap[episode.EpisodeNumber]; !ok {
formatsMap[episode.EpisodeNumber] = make([]*crunchyroll.Format, 0)
}
formatsMap[episode.EpisodeNumber] = append(formatsMap[episode.EpisodeNumber], format)
}
keys := make([]int, 0, len(formatsMap))
for k := range formatsMap {
keys = append(keys, k)
}
sort.Ints(keys)
var orderedFormats [][]*crunchyroll.Format
for _, k := range keys {
orderedFormats = append(orderedFormats, formatsMap[k])
}
return orderedFormats, nil
}
// SeasonStructure fields are nearly same as FormatStructure
type SeasonStructure struct {
*EpisodeStructure
getFunc func() ([]*crunchyroll.Season, error)
seasons []*crunchyroll.Season
}
// NewSeasonStructure returns a new SeasonStructure, based on the given formats
func NewSeasonStructure(seasons []*crunchyroll.Season) *SeasonStructure {
ss := &SeasonStructure{
seasons: seasons,
}
ss.EpisodeStructure = newEpisodeStructure(ss)
return ss
}
// Seasons returns all stored seasons
func (ss *SeasonStructure) Seasons() ([]*crunchyroll.Season, error) {
if ss.seasons == nil {
var err error
if ss.seasons, err = ss.getFunc(); err != nil {
return nil, err
}
}
return ss.seasons, nil
}
// MovieListingStructure fields are nearly same as FormatStructure
type MovieListingStructure struct {
VideoStructure
*StreamStructure
getFunc func() ([]*crunchyroll.MovieListing, error)
movieListings []*crunchyroll.MovieListing
}
// NewMovieListingStructure returns a new MovieListingStructure, based on the given formats
func NewMovieListingStructure(movieListings []*crunchyroll.MovieListing) *MovieListingStructure {
ml := &MovieListingStructure{
getFunc: func() ([]*crunchyroll.MovieListing, error) {
return movieListings, nil
},
}
ml.StreamStructure = newStreamStructure(ml)
return ml
}
// MovieListings returns all stored movie listings
func (ml *MovieListingStructure) MovieListings() ([]*crunchyroll.MovieListing, error) {
if ml.movieListings == nil {
var err error
if ml.movieListings, err = ml.getFunc(); err != nil {
return nil, err
}
}
return ml.movieListings, nil
}
// GetMovieListingByFormat returns the movie listing to which the given format belongs to.
// If the format or the parent is not stored, an error will be returned
func (ml *MovieListingStructure) GetMovieListingByFormat(format *crunchyroll.Format) (*crunchyroll.MovieListing, error) {
if !ml.initState {
if err := ml.InitAll(); err != nil {
return nil, err
}
}
formatParent, err := ml.FormatParent(format)
if err != nil {
return nil, err
}
streamParent, err := ml.StreamParent(formatParent)
if err != nil {
return nil, err
}
movieListing, ok := streamParent.(*crunchyroll.MovieListing)
if !ok {
return nil, errors.New("could not find parent movie listing")
}
return movieListing, nil
}

208
video.go Normal file
View file

@ -0,0 +1,208 @@
package crunchyroll
import (
"encoding/json"
"fmt"
)
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 [][]struct {
Height int `json:"height"`
Source string `json:"source"`
Type string `json:"type"`
Width int `json:"width"`
} `json:"poster_tall"`
PosterWide [][]struct {
Height int `json:"height"`
Source string `json:"source"`
Type string `json:"type"`
Width int `json:"width"`
} `json:"poster_wide"`
} `json:"images"`
}
type Video interface{}
type Movie struct {
video
Video
crunchy *Crunchyroll
// 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/%s/%s/movies/%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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)
movieListing := &Movie{
crunchy: crunchy,
}
if err = decodeMapToStruct(jsonBody, movieListing); err != nil {
return nil, err
}
return movieListing, nil
}
// MovieListing returns all videos corresponding with the movie.
// Beside the normal movie, sometimes movie previews are returned too, but you can try to get the actual movie
// by sorting the returning MovieListing slice with the utils.MovieListingByDuration interface
func (m *Movie) MovieListing() (movieListings []*MovieListing, err error) {
resp, err := m.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
m.crunchy.Config.CountryCode,
m.crunchy.Config.MaturityRating,
m.crunchy.Config.Channel,
m.ID,
m.crunchy.Locale,
m.crunchy.Config.Signature,
m.crunchy.Config.Policy,
m.crunchy.Config.KeyPairID))
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)
}
return movieListings, nil
}
type Series struct {
video
Video
crunchy *Crunchyroll
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/%s/%s/movies?movie_listing_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
crunchy.Config.CountryCode,
crunchy.Config.MaturityRating,
crunchy.Config.Channel,
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)
series := &Series{
crunchy: crunchy,
}
if err = decodeMapToStruct(jsonBody, series); err != nil {
return nil, err
}
return series, nil
}
// Seasons returns all seasons of a series
func (s *Series) Seasons() (seasons []*Season, err error) {
resp, err := s.crunchy.request(fmt.Sprintf("https://beta-api.crunchyroll.com/cms/v2/%s/%s/%s/seasons?series_id=%s&locale=%s&Signature=%s&Policy=%s&Key-Pair-Id=%s",
s.crunchy.Config.CountryCode,
s.crunchy.Config.MaturityRating,
s.crunchy.Config.Channel,
s.ID,
s.crunchy.Locale,
s.crunchy.Config.Signature,
s.crunchy.Config.Policy,
s.crunchy.Config.KeyPairID))
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)
}
return
}