mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 04:02:00 -06:00
Initial commit
This commit is contained in:
commit
5f1d811c66
23 changed files with 3612 additions and 0 deletions
61
LICENSE
Normal file
61
LICENSE
Normal 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
17
Makefile
Normal 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
331
README.md
Normal 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.
|
||||||
570
cmd/crunchyroll/cmd/download.go
Normal file
570
cmd/crunchyroll/cmd/download.go
Normal 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
|
||||||
|
}
|
||||||
50
cmd/crunchyroll/cmd/login.go
Normal file
50
cmd/crunchyroll/cmd/login.go
Normal 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)
|
||||||
|
}
|
||||||
68
cmd/crunchyroll/cmd/root.go
Normal file
68
cmd/crunchyroll/cmd/root.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
301
cmd/crunchyroll/cmd/utils.go
Normal file
301
cmd/crunchyroll/cmd/utils.go
Normal 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
11
cmd/crunchyroll/main.go
Normal 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
347
crunchyroll.go
Normal 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
115
crunchyroll_test.go
Normal 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
138
episode.go
Normal 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
21
error.go
Normal 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
224
format.go
Normal 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(¤t, 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
8
go.mod
Normal 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
99
movie_listing.go
Normal 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
95
season.go
Normal 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
116
stream.go
Normal 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
25
subtitle.go
Normal 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
57
utils.go
Normal 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
70
utils/locale.go
Normal 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
54
utils/sort.go
Normal 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
626
utils/structure.go
Normal 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
208
video.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue