diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..69022e2 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,11 @@ +version: 2 +jobs: + build: + working_directory: /go/src/s32x.com/anirip + docker: + - image: circleci/golang:1.11.3 + steps: + - checkout + - run: + name: Run unit tests + command: make test \ No newline at end of file diff --git a/common/error.go b/common/error.go deleted file mode 100644 index e1a419f..0000000 --- a/common/error.go +++ /dev/null @@ -1,22 +0,0 @@ -package common /* import "s32x.com/anirip/common" */ - -import "fmt" - -// Error represents a generic anirip error -type Error struct { - Msg string - Err error -} - -// NewError generates a new generic anirip error -func NewError(msg string, err error) *Error { - return &Error{Msg: msg, Err: err} -} - -// Error implements the error interface -func (e *Error) Error() string { - if e.Err != nil { - return fmt.Sprintf("Error: %v - %v", e.Msg, e.Err) - } - return fmt.Sprintf("Error: %v", e.Msg) -} diff --git a/common/httpclient.go b/common/httpclient.go index b91b805..e49bbd5 100644 --- a/common/httpclient.go +++ b/common/httpclient.go @@ -1,7 +1,6 @@ package common /* import "s32x.com/anirip/common" */ import ( - "errors" "fmt" "io" "io/ioutil" @@ -32,14 +31,15 @@ type HTTPClient struct { // NewHTTPClient generates a new HTTPClient Requester that // contains a random user-agent to emulate browser requests -func NewHTTPClient() (*HTTPClient, error) { +func NewHTTPClient() *HTTPClient { // Create the client and attach a cookie jar client := &http.Client{} client.Jar, _ = cookiejar.New(nil) + return &HTTPClient{ Client: client, UserAgent: randomUA(), - }, nil + } } // randomUA retrieves a list of user-agents and returns a @@ -81,7 +81,7 @@ func (c *HTTPClient) Get(url string, header http.Header) (*http.Response, error) // Assemble our request and attach all headers and cookies req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("creating GET request: %w", err) } if header != nil { req.Header = header @@ -97,7 +97,7 @@ func (c *HTTPClient) request(req *http.Request) (*http.Response, error) { // Executes the request res, err := c.Client.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("processing request: %w", err) } // If the server is in IUAM mode, solve the challenge and retry @@ -107,11 +107,11 @@ func (c *HTTPClient) request(req *http.Request) (*http.Response, error) { var rb []byte rb, err = ioutil.ReadAll(res.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("reading IUAM response: %w", err) } return c.bypassCF(req, rb) } - return res, err + return res, nil } // bypass attempts to re-execute a standard request after first bypassing @@ -121,7 +121,7 @@ func (c *HTTPClient) bypassCF(req *http.Request, body []byte) (*http.Response, e r1, _ := regexp.Compile(`setTimeout\(function\(\){\s+(var s,t,o,p,b,r,e,a,k,i,n,g,f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n`) r1Match := r1.FindSubmatch(body) if len(r1Match) != 2 { - return nil, errors.New("Failed to match on IUAM challenge") + return nil, fmt.Errorf("failed to match on IUAM challenge") } js := string(r1Match[1]) @@ -154,7 +154,7 @@ func (c *HTTPClient) bypassCF(req *http.Request, body []byte) (*http.Response, e passMatch := pass.FindSubmatch(body) if !(len(vcMatch) == 2 && len(passMatch) == 2) { - return nil, errors.New("Failed to extract Cloudflare IUAM challenge") + return nil, fmt.Errorf("failed to extract IUAM challenge") } // Assemble the CFClearence request @@ -168,7 +168,7 @@ func (c *HTTPClient) bypassCF(req *http.Request, body []byte) (*http.Response, e // Execute, populate cookies after 5 seconds and re-execute prior request time.Sleep(4000 * time.Millisecond) if _, err := c.Get(u.String(), nil); err != nil { - return nil, err + return nil, fmt.Errorf("getting IUAM request: %w", err) } return c.request(req) } diff --git a/common/log/log.go b/common/log/log.go index 843650c..93dcacc 100644 --- a/common/log/log.go +++ b/common/log/log.go @@ -17,4 +17,4 @@ func Warn(format string, a ...interface{}) { color.Yellow(prefix+format, a...) } func Success(format string, a ...interface{}) { color.Green(prefix+format, a...) } // Error logs errors -func Error(err error) { color.Red(prefix + err.Error()) } +func Error(err error) { color.Red(prefix + "Error: " + err.Error()) } diff --git a/common/video.go b/common/video.go index 3668888..8fc8fc5 100644 --- a/common/video.go +++ b/common/video.go @@ -1,6 +1,7 @@ package common /* import "s32x.com/anirip/common" */ import ( + "fmt" "os/exec" "path/filepath" ) @@ -25,21 +26,22 @@ func (p *VideoProcessor) DumpHLS(url string) error { "-c", "copy", "incomplete.episode.mkv") cmd.Dir = p.tempDir if err := cmd.Run(); err != nil { - return err + return fmt.Errorf("running download command: %w", err) } // Rename the file since it's no longer incomplete // and return - return Rename(p.tempDir+pathSep+"incomplete.episode.mkv", - p.tempDir+pathSep+"episode.mkv", 10) + if err := Rename(p.tempDir+pathSep+"incomplete.episode.mkv",p.tempDir+pathSep+"episode.mkv", 10); err != nil { + return fmt.Errorf("renaming incomplete episode: %w", err) + } + return nil } // MergeSubtitles merges the VIDEO.mkv and the VIDEO.ass func (p *VideoProcessor) MergeSubtitles(audioLang, subtitleLang string) error { Delete(p.tempDir, "unmerged.episode.mkv") - if err := Rename(p.tempDir+pathSep+"episode.mkv", - p.tempDir+pathSep+"unmerged.episode.mkv", 10); err != nil { - return err + if err := Rename(p.tempDir+pathSep+"episode.mkv", p.tempDir+pathSep+"unmerged.episode.mkv", 10); err != nil { + return fmt.Errorf("renaming unmerged episode: %w", err) } cmd := new(exec.Cmd) if subtitleLang == "" { @@ -65,7 +67,7 @@ func (p *VideoProcessor) MergeSubtitles(audioLang, subtitleLang string) error { } cmd.Dir = p.tempDir if err := cmd.Run(); err != nil { - return err + return fmt.Errorf("running download command: %w", err) } Delete(p.tempDir, "subtitles.episode.ass") Delete(p.tempDir, "unmerged.episode.mkv") diff --git a/crunchyroll/episode.go b/crunchyroll/episode.go index aeda6e6..0f80f47 100644 --- a/crunchyroll/episode.go +++ b/crunchyroll/episode.go @@ -2,6 +2,7 @@ package crunchyroll /* import "s32x.com/anirip/crunchyroll" */ import ( "bytes" + "fmt" "io/ioutil" "net/http" "net/url" @@ -53,13 +54,13 @@ func (e *Episode) GetEpisodeInfo(client *common.HTTPClient, quality string) erro // client.Header.Add("Referer", "http://www.crunchyroll.com/"+strings.Split(e.Path, "/")[1]) resp, err := client.Get(e.URL, nil) if err != nil { - return common.NewError("There was an error requesting the episode doc", err) + return fmt.Errorf("getting episode page: %w", err) } // Creates the document that will be used to scrape for episode metadata doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { - return common.NewError("There was an error reading the episode doc", err) + return fmt.Errorf("generating episode document: %w", err) } // Request querystring @@ -87,13 +88,13 @@ func (e *Episode) GetEpisodeInfo(client *common.HTTPClient, quality string) erro header.Add("X-Requested-With", "ShockwaveFlash/22.0.0.192") resp, err = client.Post("http://www.crunchyroll.com/xml/?"+queryString, header, reqBody) if err != nil { - return common.NewError("There was an error retrieving the manifest", err) + return fmt.Errorf("getting manifest page: %w", err) } // Gets the xml string from the received xml response body body, err := ioutil.ReadAll(resp.Body) if err != nil { - return common.NewError("There was an error reading the xml response", err) + return fmt.Errorf("reading manifest page: %w", err) } // Checks for an unsupported region first @@ -101,7 +102,7 @@ func (e *Episode) GetEpisodeInfo(client *common.HTTPClient, quality string) erro xmlString := string(body) if strings.Contains(xmlString, "") && strings.Contains(xmlString, "") { if strings.SplitN(strings.SplitN(xmlString, "", 2)[1], "", 2)[0] == "4" { - return common.NewError("This video is not available in your region", err) + return fmt.Errorf("video not avaliable in your region: %w", err) } } @@ -111,7 +112,7 @@ func (e *Episode) GetEpisodeInfo(client *common.HTTPClient, quality string) erro if strings.Contains(xmlString, "") && strings.Contains(xmlString, "") { eFile = strings.SplitN(strings.SplitN(xmlString, "", 2)[1], "", 2)[0] } else { - return common.NewError("No hosts were found for the episode", err) + return fmt.Errorf("no episode hosts found: %w", err) } e.Title = strings.Replace(strings.Replace(doc.Find("#showmedia_about_name").First().Text(), "“", "", -1), "”", "", -1) @@ -122,7 +123,10 @@ func (e *Episode) GetEpisodeInfo(client *common.HTTPClient, quality string) erro // Download downloads entire episode to our temp directory func (e *Episode) Download(vp *common.VideoProcessor) error { - return vp.DumpHLS(e.StreamURL) + if err := vp.DumpHLS(e.StreamURL); err != nil { + return fmt.Errorf("dumping HLS stream: %w", err) + } + return nil } // GetFilename returns the Episodes filename diff --git a/crunchyroll/session.go b/crunchyroll/session.go index 92406a2..ce355a2 100644 --- a/crunchyroll/session.go +++ b/crunchyroll/session.go @@ -2,6 +2,7 @@ package crunchyroll /* import "s32x.com/anirip/crunchyroll" */ import ( "bytes" + "fmt" "net/http" "net/url" "strings" @@ -16,12 +17,13 @@ func Login(c *common.HTTPClient, user, pass string) error { // Perform preflight request to retrieve the login page res, err := c.Get("https://www.crunchyroll.com/login", nil) if err != nil { - return err + return fmt.Errorf("getting login page: %w", err) } + defer res.Body.Close() doc, err := goquery.NewDocumentFromResponse(res) if err != nil { - return err + return fmt.Errorf("generating login document: %w", err) } // Scrape the login token @@ -29,12 +31,12 @@ func Login(c *common.HTTPClient, user, pass string) error { // Sets the credentials and attempts to generate new cookies if err := createSession(c, user, pass, token); err != nil { - return err + return fmt.Errorf("creating session: %w", err) } // Validates the session created and returns if err := validateSession(c); err != nil { - return err + return fmt.Errorf("validating session: %w", err) } log.Info("Successfully logged in!") return nil @@ -56,7 +58,7 @@ func createSession(c *common.HTTPClient, user, pass, token string) error { head.Add("Referer", "https://www.crunchyroll.com/login") head.Add("Content-Type", "application/x-www-form-urlencoded") if _, err := c.Post("https://www.crunchyroll.com/login", head, body); err != nil { - return common.NewError("Failed to execute authentication request", err) + return fmt.Errorf("posting auth request: %w", err) } return nil } @@ -66,17 +68,17 @@ func createSession(c *common.HTTPClient, user, pass, token string) error { func validateSession(c *common.HTTPClient) error { resp, err := c.Get("http://www.crunchyroll.com/", nil) if err != nil { - return common.NewError("Failed to execute session validation request", err) + return fmt.Errorf("getting validation page: %w", err) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { - return common.NewError("Failed to parse session validation page", err) + return fmt.Errorf("generating validation document: %w", err) } user := strings.TrimSpace(doc.Find("li.username").First().Text()) if resp.StatusCode == 200 && user != "" { return nil } - return common.NewError("Failed to verify session", nil) + return fmt.Errorf("could not verify session") } diff --git a/crunchyroll/show.go b/crunchyroll/show.go index e98ccc8..3713078 100644 --- a/crunchyroll/show.go +++ b/crunchyroll/show.go @@ -1,6 +1,7 @@ package crunchyroll /* import "s32x.com/anirip/crunchyroll" */ import ( + "fmt" "strconv" "strings" @@ -21,13 +22,13 @@ type Show struct { func (s *Show) Scrape(client *common.HTTPClient, showURL string) error { res, err := client.Get(showURL, nil) if err != nil { - return common.NewError("There was an error retrieving show page", err) + return fmt.Errorf("getting show page: %w", err) } // Creates the goquery document for scraping showDoc, err := goquery.NewDocumentFromResponse(res) if err != nil { - return common.NewError("There was an error while accessing the show page", err) + return fmt.Errorf("generating show document: %w", err) } // Sets Title, Path and URL on our show object diff --git a/crunchyroll/subtitle.go b/crunchyroll/subtitle.go index 034ce4c..794de93 100644 --- a/crunchyroll/subtitle.go +++ b/crunchyroll/subtitle.go @@ -3,10 +3,10 @@ package crunchyroll /* import "s32x.com/anirip/crunchyroll" */ import ( "bytes" "encoding/json" - "errors" + "fmt" "io/ioutil" "os" - "strings" + "regexp" "s32x.com/anirip/common" ) @@ -34,45 +34,41 @@ type configStruct struct { // DownloadSubtitles entirely downloads subtitles to our temp directory func (episode *Episode) DownloadSubtitles(client *common.HTTPClient, language string, tempDir string) (string, error) { - // Remove stale temp file to avoid conflicts in func - os.Remove(tempDir + string(os.PathSeparator) + "subtitles.episode.ass") - - // Subtitle language (The only two I know fo are enUS and jaJP) - subLang := "enUS" + subOutput := tempDir + string(os.PathSeparator) + "subtitles.episode.ass" + // Remove stale temp file to avoid conflicts in writing + os.Remove(subOutput) // Fetch html page for the episode - var body []byte - if res, err := client.Get(episode.URL, nil); err == nil { - defer res.Body.Close() - if body, err = ioutil.ReadAll(res.Body); err != nil { - return "", err - } - } else { - return "", err + res, err := client.Get(episode.URL, nil) + if err != nil { + return "", fmt.Errorf("getting episode page: %w", err) } - // Find the vilos config table and split the area after it. - // Then, trim away the "vilos.config.media = " to produce a json table in string form - stringBody := string(body) - mediaConfigIndex := strings.Index(stringBody, "vilos.config.media") - newResult := strings.SplitAfter(stringBody[mediaConfigIndex:], "}]}") - jsonReady := strings.TrimPrefix(newResult[0], "vilos.config.media = ") + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("reading episode response: %w", err) + } - // Parse json string to configStruct - var subStruct configStruct - if err := json.Unmarshal([]byte(jsonReady), &subStruct); err != nil { - return "", err + jsonResult := regexp.MustCompile("vilos.config.media = (.*);\n").FindSubmatch(body) + + if jsonResult == nil { + return "", fmt.Errorf("finding vilos config") } + var subStruct configStruct + if err := json.Unmarshal(jsonResult[1], &subStruct); err != nil { + return "", fmt.Errorf("unmarshaling json: %w", err) + } // Verify that there are actually subtitles if len(subStruct.Subtitles) == 0 { - return "", errors.New("No subtitle files found(?)") + return "", fmt.Errorf("no subtitles found (?)") } // Determine the best subtitle (I know it currently defaults to enUS but it can be fixed later) chosenSubtitle := subStruct.Subtitles[0] for _, sub := range subStruct.Subtitles { - if sub.Language == subLang { + if sub.Language == language { chosenSubtitle = sub break } @@ -81,7 +77,7 @@ func (episode *Episode) DownloadSubtitles(client *common.HTTPClient, language st // Fetch the download page for the chosen subtitle (the page that's returned is the decrypted subtitles in ass format) subResp, err := client.Get(chosenSubtitle.DownloadURL, nil) if err != nil { - return "", err + return "", fmt.Errorf("getting download url: %w", err) } // Read the subtitles and output them to the subtitles.episode.ass file in the temp directory @@ -89,8 +85,8 @@ func (episode *Episode) DownloadSubtitles(client *common.HTTPClient, language st buf := new(bytes.Buffer) buf.ReadFrom(subResp.Body) - if err := ioutil.WriteFile(tempDir + string(os.PathSeparator) + "subtitles.episode.ass", buf.Bytes(), 0777); err != nil { - return "", err + if err := ioutil.WriteFile(subOutput, buf.Bytes(), os.ModePerm); err != nil { + return "", fmt.Errorf("writing file: %w", err) } - return subLang, nil + return language, nil } diff --git a/main.go b/main.go index e826e36..cbc340d 100644 --- a/main.go +++ b/main.go @@ -42,8 +42,8 @@ func main() { app.Flags = []cli.Flag{ cli.StringFlag{ Name: "language, l", - Value: "eng", - Usage: "language code for the subtitles (not all are supported) ex: eng, esp", + Value: "en-US", + Usage: "language code for the subtitles (not all are supported) ex: en-US, ja-JP", }, cli.StringFlag{ Name: "quality, q", @@ -75,15 +75,11 @@ func download(showURL, user, pass, quality, subLang string) { _, err := os.Stat(tempDir) if err != nil { log.Info("Generating new temporary directory") - os.Mkdir(tempDir, 0777) + os.Mkdir(tempDir, os.ModePerm) } // Generate the HTTP client that will be used through whole lifecycle - client, err := common.NewHTTPClient() - if err != nil { - log.Error(err) - return - } + client := common.NewHTTPClient() // Logs the user in and stores their session data in the clients jar log.Info("Logging into Crunchyroll...") @@ -105,11 +101,11 @@ func download(showURL, user, pass, quality, subLang string) { vp := common.NewVideoProcessor(tempDir) // Creates a new show directory which will store all seasons - os.Mkdir(show.GetTitle(), 0777) + os.Mkdir(show.GetTitle(), os.ModePerm) for _, season := range show.GetSeasons() { // Creates a new season directory that will store all episodes - os.Mkdir(show.GetTitle()+string(os.PathSeparator)+seasonMap[season.GetNumber()], 0777) + os.Mkdir(show.GetTitle()+string(os.PathSeparator)+seasonMap[season.GetNumber()], os.ModePerm) for _, episode := range season.GetEpisodes() { // Retrieves more fine grained episode metadata