├── .gitignore ├── go.mod ├── .circleci └── config.yml ├── common └── common.go ├── LICENSE ├── crunchyroll ├── stream.go ├── season.go ├── session.go ├── episode.go └── download.go ├── go.sum ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | downloads/ 3 | .vscode/ 4 | 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ovo/crunchyrip 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/Jeffail/gabs/v2 v2.6.0 7 | github.com/grafov/m3u8 v0.11.1 8 | github.com/hashicorp/golang-lru v0.5.4 9 | github.com/urfave/cli/v2 v2.3.0 10 | ) 11 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. See: https://circleci.com/docs/2.0/configuration-reference 2 | version: 2.1 3 | jobs: 4 | build: 5 | working_directory: ~/repo 6 | docker: 7 | - image: circleci/golang:1.15.8 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | keys: 12 | - go-mod-v4-{{ checksum "go.sum" }} 13 | - run: 14 | name: Install Dependencies 15 | command: go mod download 16 | - save_cache: 17 | key: go-mod-v4-{{ checksum "go.sum" }} 18 | paths: 19 | - "/go/pkg/mod" 20 | - run: 21 | name: Run tests 22 | command: | 23 | mkdir -p /tmp/test-reports 24 | gotestsum --junitfile /tmp/test-reports/unit-tests.xml 25 | - store_test_results: 26 | path: /tmp/test-reports 27 | -------------------------------------------------------------------------------- /common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "strings" 4 | 5 | // UserAgent is the user agent used by the crunchyroll app 6 | const UserAgent = "Crunchyroll/4.3.0 (bundle_identifier:com.crunchyroll.iphone; build_number:1832556.306266127) iOS/14.5.0 Gravity/3.0.0" 7 | 8 | // ClientID is used for authentication - hard coded into the app 9 | const ClientID = "lrses_25zh3kjta6r9r9" 10 | 11 | // ClientSecret is used for authentication - hard coded into the app 12 | const ClientSecret = "afDXiDZ8a6ElzKQpF6o2psLbcG3iZFHs" 13 | 14 | // FormatTitle formats the titles of episodes, seasons, and series 15 | func FormatTitle(s string) string { 16 | return strings.Join(strings.Split(s, " "), "_") 17 | } 18 | 19 | // Metadata hold metadata information about the response 20 | type Metadata struct { 21 | Class string `json:"__class__"` 22 | Href string `json:"__href__"` 23 | ResourceKey string `json:"__resource_key__"` 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Peyton Arbuckle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crunchyroll/stream.go: -------------------------------------------------------------------------------- 1 | package crunchyroll 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | 7 | "github.com/Jeffail/gabs/v2" 8 | "github.com/ovo/crunchyrip/common" 9 | ) 10 | 11 | // GetStreamURL returns the stream url for the videoID and locale 12 | func GetStreamURL(c *http.Client, auth AuthConfig, path string, locale string) (string, error) { 13 | req, err := http.NewRequest(http.MethodGet, "https://beta-api.crunchyroll.com"+path+"?locale="+locale+"&Key-Pair-Id="+auth.KeyPairID+"&Policy="+auth.Policy+"&Signature="+auth.Signature, nil) 14 | 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | req.Header.Add("User-Agent", common.UserAgent) 20 | req.Header.Add("Accept-Language", "en-US;q=1.0") 21 | 22 | resp, err := c.Do(req) 23 | 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | defer resp.Body.Close() 29 | var url string 30 | 31 | body, _ := ioutil.ReadAll(resp.Body) 32 | 33 | jsonParsed, err := gabs.ParseJSON([]byte(body)) 34 | 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | url, _ = jsonParsed.Path("streams.adaptive_hls." + locale + ".url").Data().(string) 40 | 41 | return url, nil 42 | } 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Jeffail/gabs/v2 v2.6.0 h1:WdCnGaDhNa4LSRTMwhLZzJ7SRDXjABNP13SOKvCpL5w= 3 | github.com/Jeffail/gabs/v2 v2.6.0/go.mod h1:xCn81vdHKxFUuWWAaD5jCTQDNPBMh5pPs9IJ+NcziBI= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 6 | github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= 7 | github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= 8 | github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= 9 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 13 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 15 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 16 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 17 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/ovo/crunchyrip)](https://goreportcard.com/report/github.com/ovo/crunchyrip) [![CircleCI](https://circleci.com/gh/ovo/crunchyrip.svg?style=svg)](https://circleci.com/gh/ovo/crunchyrip) ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) 2 | # Crunchyrip 3 | 4 | Download full episodes from crunchyroll into a .ts media file 5 | 6 | Inspired by [anirip](https://github.com/s32x/anirip) 7 | 8 | ## Dependencies 9 | - Go 10 | - ffmpeg 11 | 12 | ## Installation 13 | Clone or download repository 14 | 15 | `$ go install` 16 | 17 | ## Usage 18 | 19 | #### For individual episodes 20 | 21 | `$ crunchyrip download --email --password --episodeIDs ,...` 22 | 23 | #### For seasons 24 | 25 | `$ crunchyrip download --email --password --seriesID ` 26 | 27 | You will be prompted to select the season you want to download for the given series 28 | 29 | Episodes will be stored in the downloads folder 30 | 31 | For more info run `$ crunchyrip [subcommand] --help` 32 | 33 | #### Episode range 34 | 35 | `$ crunchyrip download --email --password --seriesID --range -` 36 | 37 | This is useful for when you want to download multiple episodes but do not want to comma-seperate every episode or download the entire season 38 | 39 | ## Find episodeID and seriesID 40 | 41 | **If you are on beta crunchyroll, the ID should be in the url of the episode or season** 42 | 43 | ## Finding episodeID 44 | 45 | 1. Go to Crunchyroll and find the episode you want to download 46 | 2. Inspect element and paste this into console 47 | `document.getElementsByClassName('boxcontents')[0].id.split('_')[2]` 48 | 49 | ## Finding seriesID 50 | 51 | 1. Go to Crunchyroll and find the series that you want to download 52 | 2. Inspect element and paste this into console 53 | `JSON.parse(document.getElementsByClassName("show-actions")[0].attributes['data-contentmedia'].value).mediaId` 54 | -------------------------------------------------------------------------------- /crunchyroll/season.go: -------------------------------------------------------------------------------- 1 | package crunchyroll 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/ovo/crunchyrip/common" 9 | ) 10 | 11 | // Season holds information about the series season 12 | type Season struct { 13 | common.Metadata 14 | Links struct { 15 | SeasonChannel struct { 16 | Href string `json:"href"` 17 | } `json:"season/channel"` 18 | SeasonEpisodes struct { 19 | Href string `json:"href"` 20 | } `json:"season/episodes"` 21 | SeasonSeries struct { 22 | Href string `json:"href"` 23 | } `json:"season/series"` 24 | } `json:"__links__"` 25 | Actions struct { 26 | } `json:"__actions__"` 27 | ID string `json:"id"` 28 | ChannelID string `json:"channel_id"` 29 | Title string `json:"title"` 30 | SeriesID string `json:"series_id"` 31 | SeasonNumber int `json:"season_number"` 32 | IsComplete bool `json:"is_complete"` 33 | Description string `json:"description"` 34 | Keywords []interface{} `json:"keywords"` 35 | SeasonTags []string `json:"season_tags"` 36 | Images struct { 37 | } `json:"images"` 38 | IsMature bool `json:"is_mature"` 39 | MatureBlocked bool `json:"mature_blocked"` 40 | IsSubbed bool `json:"is_subbed"` 41 | IsDubbed bool `json:"is_dubbed"` 42 | IsSimulcast bool `json:"is_simulcast"` 43 | SeoTitle string `json:"seo_title"` 44 | SeoDescription string `json:"seo_description"` 45 | } 46 | 47 | // GetSeasons gets the seasons information for the given seriesID 48 | func GetSeasons(c *http.Client, auth AuthConfig, seriesID string) ([]Season, error) { 49 | type seasonsResp struct { 50 | common.Metadata 51 | Links struct { 52 | } `json:"__links__"` 53 | Actions struct { 54 | } `json:"__actions__"` 55 | Total int `json:"total"` 56 | Items []Season `json:"items"` 57 | } 58 | var seasons seasonsResp 59 | 60 | req, err := http.NewRequest(http.MethodGet, "https://beta-api.crunchyroll.com/cms/v2"+auth.Bucket+"/seasons?series_id="+seriesID+"&locale=en-US&Signature="+auth.Signature+"&Key-Pair-Id="+auth.KeyPairID+"&Policy="+auth.Policy, nil) 61 | 62 | if err != nil { 63 | return []Season{}, err 64 | } 65 | 66 | req.Header.Add("User-Agent", common.UserAgent) 67 | req.Header.Add("Accept-Language", "en-US;q=1.0") 68 | 69 | resp, err := c.Do(req) 70 | 71 | if err != nil { 72 | return []Season{}, err 73 | } 74 | 75 | defer resp.Body.Close() 76 | 77 | body, err := ioutil.ReadAll(resp.Body) 78 | 79 | if err != nil { 80 | return []Season{}, err 81 | } 82 | 83 | json.Unmarshal([]byte(body), &seasons) 84 | 85 | return seasons.Items, nil 86 | } 87 | -------------------------------------------------------------------------------- /crunchyroll/session.go: -------------------------------------------------------------------------------- 1 | package crunchyroll 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "github.com/ovo/crunchyrip/common" 14 | ) 15 | 16 | // Credentials holds crunchyroll oauth credientials 17 | type Credentials struct { 18 | AccessToken string `json:"access_token"` 19 | RefreshToken string `json:"refresh_token"` 20 | ExpiresIn int `json:"expires_in"` 21 | TokenType string `json:"token_type"` 22 | Scope string `json:"scope"` 23 | Country string `json:"country"` 24 | } 25 | 26 | // CMSInfo holds CMS info for crunchyroll streaming 27 | type CMSInfo struct { 28 | Cms struct { 29 | Bucket string `json:"bucket"` 30 | Policy string `json:"policy"` 31 | Signature string `json:"signature"` 32 | KeyPairID string `json:"key_pair_id"` 33 | Expires time.Time `json:"expires"` 34 | } `json:"cms"` 35 | ServiceAvailable bool `json:"service_available"` 36 | } 37 | 38 | // AuthConfig stores information needed for authorized requests 39 | type AuthConfig struct { 40 | AccessToken string 41 | Policy string 42 | Signature string 43 | KeyPairID string 44 | Bucket string 45 | } 46 | 47 | // Login creates a new crunchyroll session 48 | func Login(c *http.Client, user string, pass string) (Credentials, error) { 49 | data := url.Values{ 50 | "grant_type": {"password"}, 51 | "password": {pass}, 52 | "scope": {"account content offline_access"}, 53 | "username": {user}, 54 | } 55 | reader := strings.NewReader(data.Encode()) 56 | req, err := http.NewRequest(http.MethodPost, "https://beta-api.crunchyroll.com/auth/v1/token", reader) 57 | authString := base64.StdEncoding.EncodeToString([]byte(common.ClientID + ":" + common.ClientSecret)) 58 | 59 | if err != nil { 60 | return Credentials{}, err 61 | } 62 | 63 | req.Header.Add("User-Agent", common.UserAgent) 64 | req.Header.Add("Authorization", "Basic "+authString) 65 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") 66 | req.Header.Add("Accept-Language", "en-US;q=1.0") 67 | 68 | resp, err := c.Do(req) 69 | 70 | if err != nil { 71 | return Credentials{}, err 72 | } 73 | 74 | defer resp.Body.Close() 75 | 76 | var credientials Credentials 77 | body, _ := ioutil.ReadAll(resp.Body) 78 | json.Unmarshal([]byte(body), &credientials) 79 | 80 | return credientials, nil 81 | } 82 | 83 | // GetCMS gets crunchyroll CMS info 84 | func GetCMS(c *http.Client, token string) (CMSInfo, error) { 85 | req, err := http.NewRequest(http.MethodGet, "https://beta-api.crunchyroll.com/index/v2", nil) 86 | 87 | if err != nil { 88 | return CMSInfo{}, err 89 | } 90 | 91 | req.Header.Add("Authorization", "Bearer "+token) 92 | req.Header.Add("User-Agent", common.UserAgent) 93 | req.Header.Add("Accept-Language", "en-US;q=1.0") 94 | 95 | resp, err := c.Do(req) 96 | 97 | if err != nil { 98 | return CMSInfo{}, err 99 | } 100 | 101 | defer resp.Body.Close() 102 | 103 | var cms CMSInfo 104 | body, _ := ioutil.ReadAll(resp.Body) 105 | json.Unmarshal([]byte(body), &cms) 106 | 107 | if (cms.Cms.Bucket == "") || (cms.Cms.Policy == "") || (cms.Cms.Signature == "") || (cms.Cms.KeyPairID == "") { 108 | return CMSInfo{}, errors.New("could not get CMS info - check your credentials") 109 | } 110 | 111 | return cms, nil 112 | } 113 | -------------------------------------------------------------------------------- /crunchyroll/episode.go: -------------------------------------------------------------------------------- 1 | package crunchyroll 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/ovo/crunchyrip/common" 10 | ) 11 | 12 | // Episode contains stream information about the episode 13 | type Episode struct { 14 | Class string `json:"__class__"` 15 | Href string `json:"__href__"` 16 | ResourceKey string `json:"__resource_key__"` 17 | Links struct { 18 | EpisodeChannel struct { 19 | Href string `json:"href"` 20 | } `json:"episode/channel"` 21 | EpisodeNextEpisode struct { 22 | Href string `json:"href"` 23 | } `json:"episode/next_episode"` 24 | EpisodeSeason struct { 25 | Href string `json:"href"` 26 | } `json:"episode/season"` 27 | EpisodeSeries struct { 28 | Href string `json:"href"` 29 | } `json:"episode/series"` 30 | Streams struct { 31 | Href string `json:"href"` 32 | } `json:"streams"` 33 | } `json:"__links__"` 34 | Actions struct { 35 | } `json:"__actions__"` 36 | ID string `json:"id"` 37 | ChannelID string `json:"channel_id"` 38 | SeriesID string `json:"series_id"` 39 | SeriesTitle string `json:"series_title"` 40 | SeasonID string `json:"season_id"` 41 | SeasonTitle string `json:"season_title"` 42 | SeasonNumber int `json:"season_number"` 43 | Episode string `json:"episode"` 44 | EpisodeNumber int `json:"episode_number"` 45 | SequenceNumber int `json:"sequence_number"` 46 | ProductionEpisodeID string `json:"production_episode_id"` 47 | Title string `json:"title"` 48 | Description string `json:"description"` 49 | NextEpisodeID string `json:"next_episode_id"` 50 | NextEpisodeTitle string `json:"next_episode_title"` 51 | HdFlag bool `json:"hd_flag"` 52 | IsMature bool `json:"is_mature"` 53 | MatureBlocked bool `json:"mature_blocked"` 54 | EpisodeAirDate string `json:"episode_air_date"` 55 | IsSubbed bool `json:"is_subbed"` 56 | IsDubbed bool `json:"is_dubbed"` 57 | IsClip bool `json:"is_clip"` 58 | SeoTitle string `json:"seo_title"` 59 | SeoDescription string `json:"seo_description"` 60 | SeasonTags []string `json:"season_tags"` 61 | AvailableOffline bool `json:"available_offline"` 62 | MediaType string `json:"media_type"` 63 | Slug string `json:"slug"` 64 | Images struct { 65 | Thumbnail [][]struct { 66 | Width int `json:"width"` 67 | Height int `json:"height"` 68 | Type string `json:"type"` 69 | Source string `json:"source"` 70 | } `json:"thumbnail"` 71 | } `json:"images"` 72 | DurationMs int `json:"duration_ms"` 73 | IsPremiumOnly bool `json:"is_premium_only"` 74 | ListingID string `json:"listing_id"` 75 | SubtitleLocales []string `json:"subtitle_locales"` 76 | Playback string `json:"playback"` 77 | } 78 | 79 | // GetEpisodes returns a list of episodes for a given seasonID 80 | func GetEpisodes(c *http.Client, auth AuthConfig, seasonID string) ([]Episode, error) { 81 | type episodesResp struct { 82 | common.Metadata 83 | Links struct { 84 | } `json:"__links__"` 85 | Actions struct { 86 | } `json:"__actions__"` 87 | Total int `json:"total"` 88 | Items []Episode `json:"items"` 89 | } 90 | var episodes episodesResp 91 | 92 | url := "https://beta-api.crunchyroll.com/cms/v2" + auth.Bucket + "/episodes?season_id=" + seasonID + "&locale=en-US&Signature=" + auth.Signature + "&Key-Pair-Id=" + auth.KeyPairID + "&Policy=" + auth.Policy 93 | req, err := http.NewRequest(http.MethodGet, url, nil) 94 | 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | req.Header.Add("User-Agent", common.UserAgent) 100 | req.Header.Add("Accept-Language", "en-US;q=1.0") 101 | 102 | resp, err := c.Do(req) 103 | 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | defer resp.Body.Close() 109 | 110 | body, err := ioutil.ReadAll(resp.Body) 111 | 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | json.Unmarshal([]byte(body), &episodes) 117 | 118 | return episodes.Items, nil 119 | 120 | } 121 | 122 | // GetEpisode returns information about the episode 123 | func GetEpisode(c *http.Client, auth AuthConfig, videoID string) (Episode, error) { 124 | url := "https://beta-api.crunchyroll.com/cms/v2" + auth.Bucket + "/episodes/" + videoID + "?locale=en-US&Signature=" + auth.Signature + "&Key-Pair-Id=" + auth.KeyPairID + "&Policy=" + auth.Policy 125 | req, err := http.NewRequest(http.MethodGet, url, nil) 126 | 127 | if err != nil { 128 | return Episode{}, err 129 | } 130 | 131 | req.Header.Add("User-Agent", common.UserAgent) 132 | req.Header.Add("Accept-Language", "en-US;q=1.0") 133 | 134 | resp, err := c.Do(req) 135 | 136 | if err != nil { 137 | log.Fatal(err) 138 | return Episode{}, err 139 | } 140 | 141 | defer resp.Body.Close() 142 | 143 | var episode Episode 144 | body, err := ioutil.ReadAll(resp.Body) 145 | 146 | if err != nil { 147 | log.Fatal(err) 148 | return Episode{}, err 149 | } 150 | 151 | json.Unmarshal([]byte(body), &episode) 152 | 153 | return episode, nil 154 | } 155 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/http/cookiejar" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | 14 | cr "github.com/ovo/crunchyrip/crunchyroll" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | func main() { 19 | app := cli.App{ 20 | Name: "crunchyrip", 21 | Usage: "download full crunchyroll episodes", 22 | Commands: []*cli.Command{ 23 | { 24 | Name: "download", 25 | Aliases: []string{"d"}, 26 | Usage: "download episodes or a season", 27 | Flags: []cli.Flag{ 28 | &cli.StringFlag{ 29 | Name: "email", 30 | Value: "", 31 | Usage: "email for crunchyroll account", 32 | Required: true, 33 | }, 34 | &cli.StringFlag{ 35 | Name: "password", 36 | Value: "", 37 | Usage: "password for the crunchyroll account", 38 | Required: true, 39 | }, 40 | &cli.StringFlag{ 41 | Name: "episodeIDs", 42 | Value: "", 43 | Usage: "comma-separated episode IDs; no comma needed for single episode", 44 | }, 45 | &cli.StringFlag{ 46 | Name: "locale", 47 | Value: "en-US", 48 | }, 49 | &cli.StringFlag{ 50 | Name: "resolution", 51 | Value: "", 52 | Usage: "resolution of the download (ex. 1080x1920) - defaults to highest resolution", 53 | }, 54 | &cli.StringFlag{ 55 | Name: "seriesID", 56 | Value: "", 57 | Usage: "ID of the series you want to download a season for", 58 | }, 59 | &cli.StringFlag{ 60 | Name: "range", 61 | Value: "", 62 | Usage: "combine with seriesID to download a range of episodes (ex. --range G69P9MD9Y-GRGGQ42DR)", 63 | }, 64 | }, 65 | Action: downloadAction, 66 | }, 67 | { 68 | Name: "resolution", 69 | Aliases: []string{"r"}, 70 | Usage: "get resolutions for an episode", 71 | Flags: []cli.Flag{ 72 | &cli.StringFlag{ 73 | Name: "email", 74 | Value: "", 75 | Usage: "email for crunchyroll account", 76 | Required: true, 77 | }, 78 | &cli.StringFlag{ 79 | Name: "password", 80 | Value: "", 81 | Usage: "password for the crunchyroll account", 82 | Required: true, 83 | }, 84 | &cli.StringFlag{ 85 | Name: "episodeID", 86 | Value: "", 87 | Usage: "episodeID to get resolutions for", 88 | Required: true, 89 | }, 90 | }, 91 | Action: resolutionAction, 92 | }, 93 | }, 94 | } 95 | 96 | err := app.Run(os.Args) 97 | 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | 102 | } 103 | 104 | func downloadAction(c *cli.Context) error { 105 | var wg sync.WaitGroup 106 | tr := &http.Transport{ 107 | MaxIdleConns: 20, 108 | MaxIdleConnsPerHost: 20, 109 | } 110 | client := http.Client{Transport: tr} 111 | client.Jar, _ = cookiejar.New(nil) 112 | 113 | log.Println("Logging in") 114 | credentials, err := cr.Login(&client, c.String("email"), c.String("password")) 115 | 116 | if err != nil { 117 | return err 118 | } 119 | 120 | log.Println("Getting CMS info") 121 | cms, err := cr.GetCMS(&client, credentials.AccessToken) 122 | 123 | if err != nil { 124 | return err 125 | } 126 | 127 | authConfig := cr.AuthConfig{ 128 | AccessToken: credentials.AccessToken, 129 | Policy: cms.Cms.Policy, 130 | Signature: cms.Cms.Signature, 131 | KeyPairID: cms.Cms.KeyPairID, 132 | Bucket: cms.Cms.Bucket, 133 | } 134 | 135 | if c.String("episodeIDs") != "" { 136 | ids := strings.Split(c.String("episodeIDs"), ",") 137 | 138 | wg.Add(len(ids)) 139 | 140 | for _, id := range ids { 141 | go func(c *http.Client, authConfig cr.AuthConfig, id string, resolution string, locale string) { 142 | log.Println("Getting episode info for " + id) 143 | 144 | episode, err := cr.GetEpisode(c, authConfig, id) 145 | 146 | if err != nil { 147 | log.Fatal(err) 148 | } 149 | 150 | streamURL, err := cr.GetStreamURL(c, authConfig, episode.Links.Streams.Href, locale) 151 | 152 | if err != nil { 153 | log.Fatal(err) 154 | } 155 | log.Println("Downloading " + episode.ID) 156 | go cr.DownloadStream(c, authConfig, streamURL, resolution, episode, &wg) 157 | 158 | }(&client, authConfig, id, c.String("resolution"), c.String("locale")) 159 | 160 | } 161 | } 162 | 163 | if c.String("seriesID") != "" { 164 | seasons, err := cr.GetSeasons(&client, authConfig, c.String("seriesID")) 165 | 166 | if err != nil { 167 | return err 168 | } 169 | 170 | for i, s := range seasons { 171 | fmt.Println(i+1, s.Title) 172 | } 173 | 174 | reader := bufio.NewReader(os.Stdin) 175 | fmt.Print("Enter season to download: ") 176 | text, _ := reader.ReadString('\n') 177 | index, err := strconv.Atoi(strings.TrimSpace(text)) 178 | 179 | if err != nil { 180 | return err 181 | } 182 | 183 | log.Println("Getting episode info for " + seasons[index-1].Title) 184 | episodes, err := cr.GetEpisodes(&client, authConfig, seasons[index-1].ID) 185 | 186 | if err != nil { 187 | return err 188 | } 189 | 190 | var newep []cr.Episode 191 | 192 | if c.String("range") != "" { 193 | found := false 194 | eprange := strings.Split(c.String("range"), "-") 195 | for _, e := range episodes { 196 | if e.ID == eprange[0] { 197 | found = true 198 | } 199 | if found { 200 | newep = append(newep, e) 201 | } 202 | if e.ID == eprange[1] { 203 | found = false 204 | } 205 | } 206 | } else { 207 | newep = episodes 208 | } 209 | 210 | for _, e := range newep { 211 | wg.Add(1) 212 | go func(c *http.Client, authConfig cr.AuthConfig, ep cr.Episode, resolution string, locale string) { 213 | streamURL, err := cr.GetStreamURL(c, authConfig, ep.Links.Streams.Href, locale) 214 | 215 | if err != nil { 216 | log.Fatal(err) 217 | } 218 | 219 | log.Println("Downloading " + ep.ID) 220 | go cr.DownloadStream(c, authConfig, streamURL, resolution, ep, &wg) 221 | }(&client, authConfig, e, c.String("resolution"), c.String("locale")) 222 | } 223 | } 224 | 225 | wg.Wait() 226 | 227 | return nil 228 | } 229 | 230 | func resolutionAction(c *cli.Context) error { 231 | tr := &http.Transport{ 232 | MaxIdleConns: 20, 233 | MaxIdleConnsPerHost: 20, 234 | } 235 | id := c.String("episodeID") 236 | client := http.Client{Transport: tr} 237 | client.Jar, _ = cookiejar.New(nil) 238 | 239 | log.Println("Logging in") 240 | credentials, err := cr.Login(&client, c.String("email"), c.String("password")) 241 | 242 | if err != nil { 243 | return err 244 | } 245 | 246 | log.Println("Getting CMS info") 247 | cms, err := cr.GetCMS(&client, credentials.AccessToken) 248 | 249 | authConfig := cr.AuthConfig{ 250 | AccessToken: credentials.AccessToken, 251 | Policy: cms.Cms.Policy, 252 | Signature: cms.Cms.Signature, 253 | KeyPairID: cms.Cms.KeyPairID, 254 | Bucket: cms.Cms.Bucket, 255 | } 256 | 257 | log.Println("Getting episode info for " + id) 258 | 259 | episode, err := cr.GetEpisode(&client, authConfig, id) 260 | 261 | if err != nil { 262 | return err 263 | } 264 | 265 | streamURL, err := cr.GetStreamURL(&client, authConfig, episode.Links.Streams.Href, c.String("locale")) 266 | 267 | if err != nil { 268 | return err 269 | } 270 | 271 | resolutions, err := cr.GetResolutions(&client, authConfig, streamURL, episode) 272 | 273 | fmt.Println() 274 | for _, resolution := range resolutions { 275 | fmt.Println(resolution) 276 | } 277 | 278 | return nil 279 | } 280 | -------------------------------------------------------------------------------- /crunchyroll/download.go: -------------------------------------------------------------------------------- 1 | package crunchyroll 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/aes" 7 | "crypto/cipher" 8 | "encoding/binary" 9 | "errors" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "github.com/grafov/m3u8" 21 | lru "github.com/hashicorp/golang-lru" 22 | "github.com/ovo/crunchyrip/common" 23 | ) 24 | 25 | var ( 26 | // IVPlaceholder holds place for IV 27 | IVPlaceholder = []byte{0, 0, 0, 0, 0, 0, 0, 0} 28 | ) 29 | 30 | // Download holds information needed for downloading 31 | type Download struct { 32 | URI string 33 | SeqNo uint64 34 | ExtXKey *m3u8.Key 35 | totalDuration time.Duration 36 | } 37 | 38 | // DownloadStream downloads the given stream url 39 | func DownloadStream(c *http.Client, auth AuthConfig, url string, resolution string, ep Episode, wg *sync.WaitGroup) error { 40 | defer wg.Done() 41 | req, err := http.NewRequest(http.MethodGet, url, nil) 42 | 43 | if err != nil { 44 | return err 45 | } 46 | 47 | req.Header.Add("User-Agent", common.UserAgent) 48 | 49 | resp, err := c.Do(req) 50 | 51 | if err != nil { 52 | return err 53 | } 54 | 55 | defer resp.Body.Close() 56 | 57 | os.Mkdir("./downloads", 0755) 58 | os.Mkdir("./downloads/"+common.FormatTitle(ep.SeriesTitle), 0755) 59 | os.Mkdir("./downloads/"+common.FormatTitle(ep.SeriesTitle)+"/"+common.FormatTitle(ep.SeasonTitle), 0755) 60 | filePath := "./downloads/" + common.FormatTitle(ep.SeriesTitle) + "/" + common.FormatTitle(ep.SeasonTitle) + "/" + common.FormatTitle(ep.SeriesTitle) + ".s" + strconv.Itoa(ep.SeasonNumber) + ".e" + strconv.Itoa(ep.EpisodeNumber) + "." + common.FormatTitle(ep.Title) + ".ts" 61 | 62 | p, listType, err := m3u8.DecodeFrom(bufio.NewReader(resp.Body), true) 63 | 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if listType == m3u8.MASTER { 69 | masterpl := p.(*m3u8.MasterPlaylist) 70 | 71 | if resolution != "" { 72 | for i, variant := range masterpl.Variants { 73 | if variant.Resolution == resolution { 74 | msChan := make(chan *Download, 1024) 75 | go GetPlaylist(c, masterpl.Variants[i].URI, 0, true, msChan, wg) 76 | DownloadSegment(c, filePath, msChan, 0) 77 | break 78 | } 79 | } 80 | } else { 81 | msChan := make(chan *Download, 1024) 82 | go GetPlaylist(c, masterpl.Variants[0].URI, 0, true, msChan, wg) 83 | DownloadSegment(c, filePath, msChan, 0) 84 | } 85 | 86 | } 87 | return nil 88 | } 89 | 90 | // GetResolutions gets the resolutions for an episodeID 91 | func GetResolutions(c *http.Client, auth AuthConfig, url string, ep Episode) ([]string, error) { 92 | req, err := http.NewRequest(http.MethodGet, url, nil) 93 | 94 | if err != nil { 95 | return []string{}, err 96 | } 97 | 98 | req.Header.Add("User-Agent", common.UserAgent) 99 | 100 | resp, err := c.Do(req) 101 | 102 | if err != nil { 103 | return []string{}, err 104 | } 105 | 106 | defer resp.Body.Close() 107 | 108 | p, listType, err := m3u8.DecodeFrom(bufio.NewReader(resp.Body), true) 109 | 110 | if err != nil { 111 | return []string{}, err 112 | } 113 | 114 | if listType == m3u8.MEDIA { 115 | return []string{}, errors.New("incorrect m3u8 format") 116 | } 117 | 118 | playlist := p.(*m3u8.MasterPlaylist) 119 | resolutions := make([]string, len(playlist.Variants)) 120 | 121 | for i, variant := range playlist.Variants { 122 | resolutions[i] = variant.Resolution 123 | } 124 | 125 | return resolutions, nil 126 | 127 | } 128 | 129 | // DecryptData decrypts the AES-128 encrypted data 130 | func DecryptData(c *http.Client, data []byte, v *Download, aes128Keys *map[string][]byte) { 131 | var ( 132 | iv *bytes.Buffer 133 | keyData []byte 134 | cipherBlock cipher.Block 135 | ) 136 | 137 | if v.ExtXKey != nil && (v.ExtXKey.Method == "AES-128" || v.ExtXKey.Method == "aes-128") { 138 | 139 | keyData = (*aes128Keys)[v.ExtXKey.URI] 140 | 141 | if keyData == nil { 142 | req, _ := http.NewRequest("GET", v.ExtXKey.URI, nil) 143 | resp, _ := c.Do(req) 144 | keyData, _ = ioutil.ReadAll(resp.Body) 145 | resp.Body.Close() 146 | (*aes128Keys)[v.ExtXKey.URI] = keyData 147 | } 148 | 149 | if v.ExtXKey.IV == "" { 150 | iv = bytes.NewBuffer(IVPlaceholder) 151 | binary.Write(iv, binary.BigEndian, v.SeqNo) 152 | } else { 153 | iv = bytes.NewBufferString(v.ExtXKey.IV) 154 | } 155 | 156 | cipherBlock, _ = aes.NewCipher((*aes128Keys)[v.ExtXKey.URI]) 157 | cipher.NewCBCDecrypter(cipherBlock, iv.Bytes()).CryptBlocks(data, data) 158 | } 159 | 160 | } 161 | 162 | // DownloadSegment downloads the segment of the file 163 | func DownloadSegment(c *http.Client, fn string, dlc chan *Download, recTime time.Duration) { 164 | var out, err = os.Create(fn) 165 | defer out.Close() 166 | 167 | if err != nil { 168 | log.Fatal(err) 169 | return 170 | } 171 | var ( 172 | data []byte 173 | aes128Keys = &map[string][]byte{} 174 | ) 175 | 176 | for v := range dlc { 177 | req, err := http.NewRequest("GET", v.URI, nil) 178 | if err != nil { 179 | log.Fatal(err) 180 | } 181 | resp, err := c.Do(req) 182 | if err != nil { 183 | log.Print(err) 184 | continue 185 | } 186 | if resp.StatusCode != 200 { 187 | log.Printf("Received HTTP %v for %v\n", resp.StatusCode, v.URI) 188 | continue 189 | } 190 | 191 | data, _ = ioutil.ReadAll(resp.Body) 192 | resp.Body.Close() 193 | 194 | DecryptData(c, data, v, aes128Keys) 195 | 196 | _, err = out.Write(data) 197 | 198 | // _, err = io.Copy(out, resp.Body) 199 | if err != nil { 200 | log.Fatal(err) 201 | } 202 | 203 | log.Printf("Downloaded %v\n", v.URI) 204 | if recTime != 0 { 205 | log.Printf("Recorded %v of %v\n", v.totalDuration, recTime) 206 | } else { 207 | log.Printf("Recorded %v\n", v.totalDuration) 208 | } 209 | } 210 | } 211 | 212 | // GetPlaylist gets the playlist data 213 | func GetPlaylist(c *http.Client, urlStr string, recTime time.Duration, useLocalTime bool, dlc chan *Download, wg *sync.WaitGroup) { 214 | startTime := time.Now() 215 | var recDuration time.Duration = 0 216 | cache, _ := lru.New(1024) 217 | playlistURL, err := url.Parse(urlStr) 218 | if err != nil { 219 | log.Fatal(err) 220 | } 221 | for { 222 | req, err := http.NewRequest("GET", urlStr, nil) 223 | if err != nil { 224 | log.Fatal(err) 225 | } 226 | resp, err := c.Do(req) 227 | if err != nil { 228 | log.Print(err) 229 | time.Sleep(time.Duration(3) * time.Second) 230 | } 231 | playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) 232 | if err != nil { 233 | log.Fatal(err) 234 | } 235 | resp.Body.Close() 236 | if listType == m3u8.MEDIA { 237 | mpl := playlist.(*m3u8.MediaPlaylist) 238 | 239 | for segmentIndex, v := range mpl.Segments { 240 | if v != nil { 241 | var msURI string 242 | if strings.HasPrefix(v.URI, "http") { 243 | msURI, err = url.QueryUnescape(v.URI) 244 | if err != nil { 245 | log.Fatal(err) 246 | } 247 | } else { 248 | msURL, err := playlistURL.Parse(v.URI) 249 | if err != nil { 250 | log.Print(err) 251 | continue 252 | } 253 | msURI, err = url.QueryUnescape(msURL.String()) 254 | if err != nil { 255 | log.Fatal(err) 256 | } 257 | } 258 | _, hit := cache.Get(msURI) 259 | if !hit { 260 | cache.Add(msURI, nil) 261 | if useLocalTime { 262 | recDuration = time.Since(startTime) 263 | } else { 264 | recDuration += time.Duration(int64(v.Duration * 1000000000)) 265 | } 266 | dlc <- &Download{ 267 | URI: msURI, 268 | ExtXKey: mpl.Key, 269 | SeqNo: uint64(segmentIndex) + mpl.SeqNo, 270 | totalDuration: recDuration, 271 | } 272 | } 273 | if recTime != 0 && recDuration != 0 && recDuration >= recTime { 274 | close(dlc) 275 | return 276 | } 277 | } 278 | } 279 | if mpl.Closed { 280 | close(dlc) 281 | return 282 | } 283 | time.Sleep(time.Duration(int64(mpl.TargetDuration * 1000000000))) 284 | 285 | } else { 286 | log.Fatal("Not a valid media playlist") 287 | } 288 | } 289 | } 290 | --------------------------------------------------------------------------------