package commands import ( "crypto/aes" "crypto/cipher" "crypto/sha256" "fmt" "github.com/ByteDream/crunchyroll-go/v3" "github.com/ByteDream/crunchyroll-go/v3/utils" "net/http" "net/url" "os" "os/exec" "path/filepath" "reflect" "regexp" "runtime" "sort" "strconv" "strings" "sync" "time" ) var ( // ahh i love windows :))) invalidWindowsChars = []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"} invalidNotWindowsChars = []string{"/"} ) var urlFilter = regexp.MustCompile(`(S(\d+))?(E(\d+))?((-)(S(\d+))?(E(\d+))?)?(,|$)`) // systemLocale receives the system locale // https://stackoverflow.com/questions/51829386/golang-get-system-language/51831590#51831590 func systemLocale(verbose bool) crunchyroll.LOCALE { if runtime.GOOS != "windows" { if lang, ok := os.LookupEnv("LANG"); ok { var l crunchyroll.LOCALE if preSuffix := strings.Split(strings.Split(lang, ".")[0], "_"); len(preSuffix) == 1 { l = crunchyroll.LOCALE(preSuffix[0]) } else { prefix := strings.Split(lang, "_")[0] l = crunchyroll.LOCALE(fmt.Sprintf("%s-%s", prefix, preSuffix[1])) } if !utils.ValidateLocale(l) { if verbose { out.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US) } l = crunchyroll.US } return l } } else { cmd := exec.Command("powershell", "Get-Culture | select -exp Name") if output, err := cmd.Output(); err == nil { l := crunchyroll.LOCALE(strings.Trim(string(output), "\r\n")) if !utils.ValidateLocale(l) { if verbose { out.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US) } l = crunchyroll.US } return l } } if verbose { out.Err("Failed to get locale, using %s", crunchyroll.US) } return crunchyroll.US } func allLocalesAsStrings() (locales []string) { for _, locale := range utils.AllLocales { locales = append(locales, string(locale)) } sort.Strings(locales) return } type headerRoundTripper struct { http.RoundTripper header map[string]string } func (rht headerRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { resp, err := rht.RoundTripper.RoundTrip(r) if err != nil { return nil, err } for k, v := range rht.header { resp.Header.Set(k, v) } return resp, nil } func createOrDefaultClient(proxy, useragent string) (*http.Client, error) { if proxy == "" { return http.DefaultClient, nil } else { out.Info("Using custom proxy %s", proxy) proxyURL, err := url.Parse(proxy) if err != nil { return nil, err } var rt http.RoundTripper = &http.Transport{ DisableCompression: true, Proxy: http.ProxyURL(proxyURL), } if useragent != "" { rt = headerRoundTripper{ RoundTripper: rt, header: map[string]string{"User-Agent": useragent}, } } client := &http.Client{ Transport: rt, Timeout: 30 * time.Second, } return client, nil } } func freeFileName(filename string) (string, bool) { ext := filepath.Ext(filename) base := strings.TrimSuffix(filename, ext) // checks if a .tar stands before the "actual" file ending if extraExt := filepath.Ext(base); extraExt == ".tar" { ext = extraExt + ext base = strings.TrimSuffix(base, extraExt) } j := 0 for ; ; j++ { if _, stat := os.Stat(filename); stat != nil && !os.IsExist(stat) { break } filename = fmt.Sprintf("%s (%d)%s", base, j+1, ext) } return filename, j != 0 } func loadCrunchy() { out.SetProgress("Logging in") tmpFilePath := filepath.Join(os.TempDir(), ".crunchy") if _, statErr := os.Stat(tmpFilePath); !os.IsNotExist(statErr) { body, err := os.ReadFile(tmpFilePath) if err != nil { out.StopProgress("Failed to read login information: %v", err) os.Exit(1) } if crunchy, err = crunchyroll.LoginWithEtpRt(string(body), systemLocale(true), client); err != nil { out.Debug("Failed to login with temp etp rt cookie: %v", err) } else { out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body) out.StopProgress("Logged in") return } } if configDir, err := os.UserConfigDir(); err == nil { persistentFilePath := filepath.Join(configDir, "crunchy-cli", "crunchy") if _, statErr := os.Stat(persistentFilePath); statErr == nil { body, err := os.ReadFile(persistentFilePath) if err != nil { out.StopProgress("Failed to read login information: %v", err) os.Exit(1) } split := strings.SplitN(string(body), "\n", 2) if len(split) == 1 || split[1] == "" { if strings.HasPrefix(split[0], "aes:") { encrypted := body[4:] out.StopProgress("Credentials are encrypted") fmt.Print("Enter password to encrypt it: ") passwd, err := readLineSilent() fmt.Println() if err != nil { out.Err("Failed to read password; %w", err) os.Exit(1) } out.SetProgress("Logging in") hashedPassword := sha256.Sum256(passwd) block, err := aes.NewCipher(hashedPassword[:]) if err != nil { out.Err("Failed to create block: %w", err) os.Exit(1) } gcm, err := cipher.NewGCM(block) if err != nil { out.Err("Failed to create gcm: %w", err) os.Exit(1) } nonce, c := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():] b, err := gcm.Open(nil, nonce, c, nil) if err != nil { out.StopProgress("Invalid password") os.Exit(1) } split = strings.SplitN(string(b), "\n", 2) } } if len(split) == 2 { if crunchy, err = crunchyroll.LoginWithCredentials(split[0], split[1], systemLocale(true), client); err != nil { out.StopProgress(err.Error()) os.Exit(1) } out.Debug("Logged in with credentials") } else { if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], systemLocale(true), client); err != nil { out.StopProgress(err.Error()) os.Exit(1) } out.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", split[0]) } // the etp rt is written to a temp file to reduce the amount of re-logging in. // it seems like that crunchyroll has also a little cooldown if a user logs in too often in a short time os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600) out.StopProgress("Logged in") return } } out.StopProgress("To use this command, login first. Type `%s login -h` to get help", os.Args[0]) os.Exit(1) } func hasFFmpeg() bool { return exec.Command("ffmpeg", "-h").Run() == nil } func terminalWidth() int { if runtime.GOOS != "windows" { cmd := exec.Command("stty", "size") cmd.Stdin = os.Stdin res, err := cmd.Output() if err != nil { return 60 } // on alpine linux the command `stty size` does not respond the terminal size // but something like "stty: standard input". this may also apply to other systems splitOutput := strings.SplitN(strings.ReplaceAll(string(res), "\n", ""), " ", 2) if len(splitOutput) == 1 { return 60 } width, err := strconv.Atoi(splitOutput[1]) if err != nil { return 60 } return width } return 60 } func generateFilename(name, directory string) string { if runtime.GOOS != "windows" { for _, char := range invalidNotWindowsChars { name = strings.ReplaceAll(name, char, "") } out.Debug("Replaced invalid characters (not windows)") } else { for _, char := range invalidWindowsChars { name = strings.ReplaceAll(name, char, "") } out.Debug("Replaced invalid characters (windows)") } filename, changed := freeFileName(filepath.Join(directory, name)) if changed { out.Debug("File `%s` already exists, changing name to `%s`", filepath.Base(name), filepath.Base(filename)) } return filename } func extractEpisodes(url string, locales ...crunchyroll.LOCALE) ([][]*crunchyroll.Episode, error) { var matches [][]string lastOpen := strings.LastIndex(url, "[") if strings.HasSuffix(url, "]") && lastOpen != -1 && lastOpen < len(url) { matches = urlFilter.FindAllStringSubmatch(url[lastOpen+1:len(url)-1], -1) var all string for _, match := range matches { all += match[0] } if all != url[lastOpen+1:len(url)-1] { return nil, fmt.Errorf("invalid episode filter") } url = url[:lastOpen] } final := make([][]*crunchyroll.Episode, len(locales)) episodes, err := crunchy.ExtractEpisodesFromUrl(url, locales...) if err != nil { return nil, fmt.Errorf("failed to get episodes: %v", err) } if len(episodes) == 0 { return nil, fmt.Errorf("no episodes found") } if matches != nil { for _, match := range matches { fromSeason, fromEpisode, toSeason, toEpisode := -1, -1, -1, -1 if match[2] != "" { fromSeason, _ = strconv.Atoi(match[2]) } if match[4] != "" { fromEpisode, _ = strconv.Atoi(match[4]) } if match[8] != "" { toSeason, _ = strconv.Atoi(match[8]) } if match[10] != "" { toEpisode, _ = strconv.Atoi(match[10]) } if match[6] != "-" { toSeason = fromSeason toEpisode = fromEpisode } tmpEps := make([]*crunchyroll.Episode, 0) for _, episode := range episodes { if fromSeason != -1 && (episode.SeasonNumber < fromSeason || (fromEpisode != -1 && episode.EpisodeNumber < fromEpisode)) { continue } else if fromSeason == -1 && fromEpisode != -1 && episode.EpisodeNumber < fromEpisode { continue } else if toSeason != -1 && (episode.SeasonNumber > toSeason || (toEpisode != -1 && episode.EpisodeNumber > toEpisode)) { continue } else if toSeason == -1 && toEpisode != -1 && episode.EpisodeNumber > toEpisode { continue } else { tmpEps = append(tmpEps, episode) } } if len(tmpEps) == 0 { return nil, fmt.Errorf("no episodes are matching the given filter") } episodes = tmpEps } } localeSorted, err := utils.SortEpisodesByAudio(episodes) if err != nil { return nil, fmt.Errorf("failed to get audio locale: %v", err) } for i, locale := range locales { final[i] = append(final[i], localeSorted[locale]...) } return final, nil } type formatInformation struct { // the format to download format *crunchyroll.Format // additional formats which are only used by archive.go additionalFormats []*crunchyroll.Format Title string `json:"title"` SeriesName string `json:"series_name"` SeasonName string `json:"season_name"` SeasonNumber int `json:"season_number"` EpisodeNumber int `json:"episode_number"` Resolution string `json:"resolution"` FPS float64 `json:"fps"` Audio crunchyroll.LOCALE `json:"audio"` Subtitle crunchyroll.LOCALE `json:"subtitle"` } func (fi formatInformation) Format(source string) string { fields := reflect.TypeOf(fi) values := reflect.ValueOf(fi) for i := 0; i < fields.NumField(); i++ { var valueAsString string switch value := values.Field(i); value.Kind() { case reflect.String: valueAsString = value.String() case reflect.Int: valueAsString = fmt.Sprintf("%02d", value.Int()) case reflect.Float64: valueAsString = fmt.Sprintf("%.2f", value.Float()) case reflect.Bool: valueAsString = fields.Field(i).Tag.Get("json") if !value.Bool() { valueAsString = "no " + valueAsString } } if runtime.GOOS != "windows" { for _, char := range invalidNotWindowsChars { valueAsString = strings.ReplaceAll(valueAsString, char, "") } out.Debug("Replaced invalid characters (not windows)") } else { for _, char := range invalidWindowsChars { valueAsString = strings.ReplaceAll(valueAsString, char, "") } out.Debug("Replaced invalid characters (windows)") } source = strings.ReplaceAll(source, "{"+fields.Field(i).Tag.Get("json")+"}", valueAsString) } return source } type downloadProgress struct { Prefix string Message string Total int Current int Dev bool Quiet bool lock sync.Mutex } func (dp *downloadProgress) Update() { dp.update("", false) } func (dp *downloadProgress) UpdateMessage(msg string, permanent bool) { dp.update(msg, permanent) } func (dp *downloadProgress) update(msg string, permanent bool) { if dp.Quiet { return } if dp.Current >= dp.Total { return } dp.lock.Lock() defer dp.lock.Unlock() dp.Current++ if msg == "" { msg = dp.Message } if permanent { dp.Message = msg } if dp.Dev { fmt.Printf("%s%s\n", dp.Prefix, msg) return } percentage := float32(dp.Current) / float32(dp.Total) * 100 pre := fmt.Sprintf("%s%s [", dp.Prefix, msg) post := fmt.Sprintf("]%4d%% %8d/%d", int(percentage), dp.Current, dp.Total) // I don't really know why +2 is needed here but without it the Printf below would not print to the line end progressWidth := terminalWidth() - len(pre) - len(post) + 2 repeatCount := int(percentage / float32(100) * float32(progressWidth)) // it can be lower than zero when the terminal is very tiny if repeatCount < 0 { repeatCount = 0 } progressPercentage := strings.Repeat("=", repeatCount) if dp.Current != dp.Total { progressPercentage += ">" } fmt.Printf("\r%s%-"+fmt.Sprint(progressWidth)+"s%s", pre, progressPercentage, post) }