diff --git a/Makefile b/Makefile index 4266a91..7836af9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ DESTDIR= PREFIX=/usr build: - go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(BINARY_NAME) . + go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(BINARY_NAME) . clean: rm -f $(BINARY_NAME) $(VERSION_BINARY_NAME)_* @@ -24,8 +24,8 @@ uninstall: rm -f $(DESTDIR)$(PREFIX)/share/licenses/crunchy-cli/LICENSE release: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux . - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe . - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/commands.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_linux . + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_windows.exe . + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'github.com/ByteDream/crunchy-cli/utils.Version=$(VERSION)'" -o $(VERSION_BINARY_NAME)_darwin . strip $(VERSION_BINARY_NAME)_linux diff --git a/commands/archive.go b/cli/commands/archive/archive.go similarity index 67% rename from commands/archive.go rename to cli/commands/archive/archive.go index 5ad6900..29ee836 100644 --- a/commands/archive.go +++ b/cli/commands/archive/archive.go @@ -1,15 +1,14 @@ -package commands +package archive import ( - "archive/tar" - "archive/zip" "bufio" "bytes" - "compress/gzip" "context" "fmt" + "github.com/ByteDream/crunchy-cli/cli/commands" + "github.com/ByteDream/crunchy-cli/utils" "github.com/ByteDream/crunchyroll-go/v3" - "github.com/ByteDream/crunchyroll-go/v3/utils" + crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils" "github.com/grafov/m3u8" "github.com/spf13/cobra" "io" @@ -23,8 +22,6 @@ import ( "sort" "strconv" "strings" - "sync" - "time" ) var ( @@ -42,39 +39,39 @@ var ( archiveGoroutinesFlag int ) -var archiveCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "archive", Short: "Stores the given videos with all subtitles and multiple audios in a .mkv file", Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - out.Debug("Validating arguments") + utils.Log.Debug("Validating arguments") - if !hasFFmpeg() { + if !utils.HasFFmpeg() { return fmt.Errorf("ffmpeg is needed to run this command correctly") } - out.Debug("FFmpeg detected") + utils.Log.Debug("FFmpeg detected") if filepath.Ext(archiveOutputFlag) != ".mkv" { return fmt.Errorf("currently only matroska / .mkv files are supported") } for _, locale := range archiveLanguagesFlag { - if !utils.ValidateLocale(crunchyroll.LOCALE(locale)) { + if !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(locale)) { // if locale is 'all', match all known locales if locale == "all" { - archiveLanguagesFlag = allLocalesAsStrings() + archiveLanguagesFlag = utils.LocalesAsStrings() break } - return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(allLocalesAsStrings(), ", ")) + return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(utils.LocalesAsStrings(), ", ")) } } - out.Debug("Using following audio locales: %s", strings.Join(archiveLanguagesFlag, ", ")) + utils.Log.Debug("Using following audio locales: %s", strings.Join(archiveLanguagesFlag, ", ")) var found bool for _, mode := range []string{"auto", "audio", "video"} { if archiveMergeFlag == mode { - out.Debug("Using %s merge behavior", archiveMergeFlag) + utils.Log.Debug("Using %s merge behavior", archiveMergeFlag) found = true break } @@ -87,7 +84,7 @@ var archiveCmd = &cobra.Command{ found = false for _, algo := range []string{".tar", ".tar.gz", ".tgz", ".zip"} { if strings.HasSuffix(archiveCompressFlag, algo) { - out.Debug("Using %s compression", algo) + utils.Log.Debug("Using %s compression", algo) found = true break } @@ -100,8 +97,8 @@ var archiveCmd = &cobra.Command{ switch archiveResolutionFlag { case "1080p", "720p", "480p", "360p": - intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84) - archiveResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(downloadResolutionFlag, "p")) + intRes, _ := strconv.ParseFloat(strings.TrimSuffix(archiveResolutionFlag, "p"), 84) + archiveResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(archiveResolutionFlag, "p")) case "240p": // 240p would round up to 427x240 if used in the case statement above, so it has to be handled separately archiveResolutionFlag = "428x240" @@ -109,31 +106,33 @@ var archiveCmd = &cobra.Command{ default: return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag) } - out.Debug("Using resolution '%s'", archiveResolutionFlag) + utils.Log.Debug("Using resolution '%s'", archiveResolutionFlag) return nil }, RunE: func(cmd *cobra.Command, args []string) error { - loadCrunchy() + if err := commands.LoadCrunchy(); err != nil { + return err + } return archive(args) }, } func init() { - archiveCmd.Flags().StringSliceVarP(&archiveLanguagesFlag, + Cmd.Flags().StringSliceVarP(&archiveLanguagesFlag, "language", "l", - []string{string(systemLocale(false)), string(crunchyroll.JP)}, + []string{string(utils.SystemLocale(false)), string(crunchyroll.JP)}, "Audio locale which should be downloaded. Can be used multiple times") cwd, _ := os.Getwd() - archiveCmd.Flags().StringVarP(&archiveDirectoryFlag, + Cmd.Flags().StringVarP(&archiveDirectoryFlag, "directory", "d", cwd, "The directory to store the files into") - archiveCmd.Flags().StringVarP(&archiveOutputFlag, + Cmd.Flags().StringVarP(&archiveOutputFlag, "output", "o", "{title}.mkv", @@ -148,13 +147,13 @@ func init() { "\t{audio} » Audio locale of the video\n"+ "\t{subtitle} » Subtitle locale of the video") - archiveCmd.Flags().StringVarP(&archiveMergeFlag, + Cmd.Flags().StringVarP(&archiveMergeFlag, "merge", "m", "auto", "Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'") - archiveCmd.Flags().StringVarP(&archiveCompressFlag, + Cmd.Flags().StringVarP(&archiveCompressFlag, "compress", "c", "", @@ -162,7 +161,7 @@ func init() { "This flag sets the name of the compressed output file. The file ending specifies the compression algorithm. "+ "The following algorithms are supported: gzip, tar, zip") - archiveCmd.Flags().StringVarP(&archiveResolutionFlag, + Cmd.Flags().StringVarP(&archiveResolutionFlag, "resolution", "r", "best", @@ -171,51 +170,49 @@ func init() { "\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+ "\tAvailable common-use words: best (best available resolution), worst (worst available resolution)") - archiveCmd.Flags().IntVarP(&archiveGoroutinesFlag, + Cmd.Flags().IntVarP(&archiveGoroutinesFlag, "goroutines", "g", runtime.NumCPU(), "Number of parallel segment downloads") - - rootCmd.AddCommand(archiveCmd) } func archive(urls []string) error { for i, url := range urls { - out.SetProgress("Parsing url %d", i+1) + utils.Log.SetProcess("Parsing url %d", i+1) episodes, err := archiveExtractEpisodes(url) if err != nil { - out.StopProgress("Failed to parse url %d", i+1) - if crunchy.Config.Premium { - out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " + + utils.Log.StopProcess("Failed to parse url %d", i+1) + if utils.Crunchy.Config.Premium { + utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " + "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information") } return err } - out.StopProgress("Parsed url %d", i+1) + utils.Log.StopProcess("Parsed url %d", i+1) var compressFile *os.File - var c compress + var c Compress if archiveCompressFlag != "" { - compressFile, err = os.Create(generateFilename(archiveCompressFlag, "")) + compressFile, err = os.Create(utils.GenerateFilename(archiveCompressFlag, "")) if err != nil { return fmt.Errorf("failed to create archive file: %v", err) } if strings.HasSuffix(archiveCompressFlag, ".tar") { - c = newTarCompress(compressFile) + c = NewTarCompress(compressFile) } else if strings.HasSuffix(archiveCompressFlag, ".tar.gz") || strings.HasSuffix(archiveCompressFlag, ".tgz") { - c = newGzipCompress(compressFile) + c = NewGzipCompress(compressFile) } else if strings.HasSuffix(archiveCompressFlag, ".zip") { - c = newZipCompress(compressFile) + c = NewZipCompress(compressFile) } } for _, season := range episodes { - out.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber) + utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber) for j, info := range season { - out.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)", + utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)", j+1, info.Title, info.Resolution, @@ -224,26 +221,26 @@ func archive(urls []string) error { info.EpisodeNumber) } } - out.Empty() + utils.Log.Empty() for j, season := range episodes { for k, info := range season { var filename string var writeCloser io.WriteCloser if c != nil { - filename = info.Format(archiveOutputFlag) + filename = info.FormatString(archiveOutputFlag) writeCloser, err = c.NewFile(info) if err != nil { return fmt.Errorf("failed to pre generate new archive file: %v", err) } } else { - dir := info.Format(archiveDirectoryFlag) + dir := info.FormatString(archiveDirectoryFlag) if _, err = os.Stat(dir); os.IsNotExist(err) { if err = os.MkdirAll(dir, 0777); err != nil { return fmt.Errorf("error while creating directory: %v", err) } } - filename = generateFilename(info.Format(archiveOutputFlag), dir) + filename = utils.GenerateFilename(info.FormatString(archiveOutputFlag), dir) writeCloser, err = os.Create(filename) if err != nil { return fmt.Errorf("failed to create new file: %v", err) @@ -264,7 +261,7 @@ func archive(urls []string) error { writeCloser.Close() if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 { - out.Empty() + utils.Log.Empty() } } } @@ -278,8 +275,8 @@ func archive(urls []string) error { return nil } -func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename string) error { - out.Debug("Entering season %d, episode %d with %d additional formats", info.SeasonNumber, info.EpisodeNumber, len(info.additionalFormats)) +func archiveInfo(info utils.FormatInformation, writeCloser io.WriteCloser, filename string) error { + utils.Log.Debug("Entering season %d, episode %d with %d additional formats", info.SeasonNumber, info.EpisodeNumber, len(info.AdditionalFormats)) dp, err := createArchiveProgress(info) if err != nil { @@ -307,7 +304,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st return nil } - if out.IsDev() { + if utils.Log.IsDev() { dp.UpdateMessage(fmt.Sprintf("Downloading %d/%d (%.2f%%) » %s", current, total, float32(current)/float32(total)*100, segment.URI), false) } else { dp.Update() @@ -325,8 +322,8 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st select { case <-sig: signal.Stop(sig) - out.Exit("Exiting... (may take a few seconds)") - out.Exit("To force exit press ctrl+c (again)") + utils.Log.Err("Exiting... (may take a few seconds)") + utils.Log.Err("To force exit press ctrl+c (again)") cancel() // os.Exit(1) is not called since an immediate exit after the cancel function does not let // the download process enough time to stop gratefully. A result of this is that the temporary @@ -335,15 +332,15 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st // this is just here to end the goroutine and prevent it from running forever without a reason } }() - out.Debug("Set up signal catcher") + utils.Log.Debug("Set up signal catcher") var additionalDownloaderOpts []string var mergeMessage string switch archiveMergeFlag { case "auto": additionalDownloaderOpts = []string{"-vn"} - for _, format := range info.additionalFormats { - if format.Video.Bandwidth != info.format.Video.Bandwidth { + for _, format := range info.AdditionalFormats { + if format.Video.Bandwidth != info.Format.Video.Bandwidth { // revoke the changed FFmpegOpts above additionalDownloaderOpts = []string{} break @@ -361,12 +358,12 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st mergeMessage = "merging video for additional formats" } - out.Info("Downloading episode `%s` to `%s` (%s)", info.Title, filepath.Base(filename), mergeMessage) - out.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber) - out.Info("\tAudio: %s", info.Audio) - out.Info("\tSubtitle: %s", info.Subtitle) - out.Info("\tResolution: %spx", info.Resolution) - out.Info("\tFPS: %.2f", info.FPS) + utils.Log.Info("Downloading episode `%s` to `%s` (%s)", info.Title, filepath.Base(filename), mergeMessage) + utils.Log.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber) + utils.Log.Info("\tAudio: %s", info.Audio) + utils.Log.Info("\tSubtitle: %s", info.Subtitle) + utils.Log.Info("\tResolution: %spx", info.Resolution) + utils.Log.Info("\tFPS: %.2f", info.FPS) var videoFiles, audioFiles, subtitleFiles []string defer func() { @@ -376,7 +373,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st }() var f []string - if f, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.format); err != nil { + if f, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.Format); err != nil { if err != ctx.Err() { return fmt.Errorf("error while downloading: %v", err) } @@ -387,29 +384,29 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st if len(additionalDownloaderOpts) == 0 { var videos []string downloader.FFmpegOpts = additionalDownloaderOpts - if videos, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.additionalFormats...); err != nil { + if videos, err = archiveDownloadVideos(downloader, filepath.Base(filename), true, info.AdditionalFormats...); err != nil { return fmt.Errorf("error while downloading additional videos: %v", err) } downloader.FFmpegOpts = []string{} videoFiles = append(videoFiles, videos...) } else { var audios []string - if audios, err = archiveDownloadVideos(downloader, filepath.Base(filename), false, info.additionalFormats...); err != nil { + if audios, err = archiveDownloadVideos(downloader, filepath.Base(filename), false, info.AdditionalFormats...); err != nil { return fmt.Errorf("error while downloading additional videos: %v", err) } audioFiles = append(audioFiles, audios...) } - sort.Sort(utils.SubtitlesByLocale(info.format.Subtitles)) + sort.Sort(crunchyUtils.SubtitlesByLocale(info.Format.Subtitles)) sortSubtitles, _ := strconv.ParseBool(os.Getenv("SORT_SUBTITLES")) if sortSubtitles && len(archiveLanguagesFlag) > 0 { // this sort the subtitle locales after the languages which were specified // with the `archiveLanguagesFlag` flag for _, language := range archiveLanguagesFlag { - for i, subtitle := range info.format.Subtitles { + for i, subtitle := range info.Format.Subtitles { if subtitle.Locale == crunchyroll.LOCALE(language) { - info.format.Subtitles = append([]*crunchyroll.Subtitle{subtitle}, append(info.format.Subtitles[:i], info.format.Subtitles[i+1:]...)...) + info.Format.Subtitles = append([]*crunchyroll.Subtitle{subtitle}, append(info.Format.Subtitles[:i], info.Format.Subtitles[i+1:]...)...) break } } @@ -417,7 +414,7 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st } var subtitles []string - if subtitles, err = archiveDownloadSubtitles(filepath.Base(filename), info.format.Subtitles...); err != nil { + if subtitles, err = archiveDownloadSubtitles(filepath.Base(filename), info.Format.Subtitles...); err != nil { return fmt.Errorf("error while downloading subtitles: %v", err) } subtitleFiles = append(subtitleFiles, subtitles...) @@ -429,22 +426,22 @@ func archiveInfo(info formatInformation, writeCloser io.WriteCloser, filename st dp.UpdateMessage("Download finished", false) signal.Stop(sig) - out.Debug("Stopped signal catcher") + utils.Log.Debug("Stopped signal catcher") - out.Empty() + utils.Log.Empty() return nil } -func createArchiveProgress(info formatInformation) (*downloadProgress, error) { +func createArchiveProgress(info utils.FormatInformation) (*commands.DownloadProgress, error) { var progressCount int - if err := info.format.InitVideo(); err != nil { + if err := info.Format.InitVideo(); err != nil { return nil, fmt.Errorf("error while initializing a video: %v", err) } // + number of segments a video has +1 is for merging - progressCount += int(info.format.Video.Chunklist.Count()) + 1 - for _, f := range info.additionalFormats { - if f == info.format { + progressCount += int(info.Format.Video.Chunklist.Count()) + 1 + for _, f := range info.AdditionalFormats { + if f == info.Format { continue } @@ -455,16 +452,16 @@ func createArchiveProgress(info formatInformation) (*downloadProgress, error) { progressCount += int(f.Video.Chunklist.Count()) + 1 } - dp := &downloadProgress{ - Prefix: out.InfoLog.Prefix(), + dp := &commands.DownloadProgress{ + Prefix: utils.Log.(*commands.Logger).InfoLog.Prefix(), Message: "Downloading video", // number of segments a video +1 is for the success message Total: progressCount + 1, - Dev: out.IsDev(), - Quiet: out.IsQuiet(), + Dev: utils.Log.IsDev(), + Quiet: utils.Log.(*commands.Logger).IsQuiet(), } - if out.IsDev() { - dp.Prefix = out.DebugLog.Prefix() + if utils.Log.IsDev() { + dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix() } return dp, nil @@ -497,7 +494,7 @@ func archiveDownloadVideos(downloader crunchyroll.Downloader, filename string, v } f.Close() - out.Debug("Downloaded '%s' video", format.AudioLocale) + utils.Log.Debug("Downloaded '%s' video", format.AudioLocale) } return files, nil @@ -522,7 +519,7 @@ func archiveDownloadSubtitles(filename string, subtitles ...*crunchyroll.Subtitl } f.Close() - out.Debug("Downloaded '%s' subtitles", subtitle.Locale) + utils.Log.Debug("Downloaded '%s' subtitles", subtitle.Locale) } return files, nil @@ -537,9 +534,9 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s maps = append(maps, "-map", strconv.Itoa(i)) locale := crunchyroll.LOCALE(re.FindStringSubmatch(video)[1]) metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("language=%s", locale)) - metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale))) + metadata = append(metadata, fmt.Sprintf("-metadata:s:v:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale))) metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", locale)) - metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale))) + metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale))) } for i, audio := range audioFiles { @@ -547,7 +544,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s maps = append(maps, "-map", strconv.Itoa(i+len(videoFiles))) locale := crunchyroll.LOCALE(re.FindStringSubmatch(audio)[1]) metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("language=%s", locale)) - metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale))) + metadata = append(metadata, fmt.Sprintf("-metadata:s:a:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale))) } for i, subtitle := range subtitleFiles { @@ -555,7 +552,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s maps = append(maps, "-map", strconv.Itoa(i+len(videoFiles)+len(audioFiles))) locale := crunchyroll.LOCALE(re.FindStringSubmatch(subtitle)[1]) metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("language=%s", locale)) - metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("title=%s", utils.LocaleLanguage(locale))) + metadata = append(metadata, fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("title=%s", crunchyUtils.LocaleLanguage(locale))) } commandOptions := []string{"-y"} @@ -577,7 +574,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s commandOptions = append(commandOptions, "-disposition:s:0", "0", "-c", "copy", "-f", "matroska", file.Name()) // just a little nicer debug output to copy and paste the ffmpeg for debug reasons - if out.IsDev() { + if utils.Log.IsDev() { var debugOptions []string for _, option := range commandOptions { @@ -591,7 +588,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s debugOptions = append(debugOptions, option) } } - out.Debug("FFmpeg merge command: ffmpeg %s", strings.Join(debugOptions, " ")) + utils.Log.Debug("FFmpeg merge command: ffmpeg %s", strings.Join(debugOptions, " ")) } var errBuf bytes.Buffer @@ -611,7 +608,7 @@ func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, s return err } -func archiveExtractEpisodes(url string) ([][]formatInformation, error) { +func archiveExtractEpisodes(url string) ([][]utils.FormatInformation, error) { var hasJapanese bool languagesAsLocale := []crunchyroll.LOCALE{crunchyroll.JP} for _, language := range archiveLanguagesFlag { @@ -623,7 +620,7 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { } } - episodes, err := extractEpisodes(url, languagesAsLocale...) + episodes, err := utils.ExtractEpisodes(url, languagesAsLocale...) if err != nil { return nil, err } @@ -634,9 +631,9 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { for i, eps := range episodes { if len(eps) == 0 { - out.SetProgress("%s has no matching episodes", languagesAsLocale[i]) + utils.Log.SetProcess("%s has no matching episodes", languagesAsLocale[i]) } else if len(episodes[0]) > len(eps) { - out.SetProgress("%s has %d less episodes than existing in japanese (%d)", languagesAsLocale[i], len(episodes[0])-len(eps), len(episodes[0])) + utils.Log.SetProcess("%s has %d less episodes than existing in japanese (%d)", languagesAsLocale[i], len(episodes[0])-len(eps), len(episodes[0])) } } @@ -644,11 +641,11 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { episodes = episodes[1:] } - eps := make(map[int]map[int]*formatInformation) + eps := make(map[int]map[int]*utils.FormatInformation) for _, lang := range episodes { - for _, season := range utils.SortEpisodesBySeason(lang) { + for _, season := range crunchyUtils.SortEpisodesBySeason(lang) { if _, ok := eps[season[0].SeasonNumber]; !ok { - eps[season[0].SeasonNumber] = map[int]*formatInformation{} + eps[season[0].SeasonNumber] = map[int]*utils.FormatInformation{} } for _, episode := range season { format, err := episode.GetFormat(archiveResolutionFlag, "", false) @@ -657,9 +654,9 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { } if _, ok := eps[episode.SeasonNumber][episode.EpisodeNumber]; !ok { - eps[episode.SeasonNumber][episode.EpisodeNumber] = &formatInformation{ - format: format, - additionalFormats: make([]*crunchyroll.Format, 0), + eps[episode.SeasonNumber][episode.EpisodeNumber] = &utils.FormatInformation{ + Format: format, + AdditionalFormats: make([]*crunchyroll.Format, 0), Title: episode.Title, SeriesName: episode.SeriesTitle, @@ -671,15 +668,15 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { Audio: format.AudioLocale, } } else { - eps[episode.SeasonNumber][episode.EpisodeNumber].additionalFormats = append(eps[episode.SeasonNumber][episode.EpisodeNumber].additionalFormats, format) + eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats = append(eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats, format) } } } } - var infoFormat [][]formatInformation + var infoFormat [][]utils.FormatInformation for _, e := range eps { - var tmpFormatInfo []formatInformation + var tmpFormatInfo []utils.FormatInformation var keys []int for episodeNumber := range e { @@ -696,124 +693,3 @@ func archiveExtractEpisodes(url string) ([][]formatInformation, error) { return infoFormat, nil } - -type compress interface { - io.Closer - - NewFile(information formatInformation) (io.WriteCloser, error) -} - -func newGzipCompress(file *os.File) *tarCompress { - gw := gzip.NewWriter(file) - return &tarCompress{ - parent: gw, - dst: tar.NewWriter(gw), - } -} - -func newTarCompress(file *os.File) *tarCompress { - return &tarCompress{ - dst: tar.NewWriter(file), - } -} - -type tarCompress struct { - compress - - wg sync.WaitGroup - - parent *gzip.Writer - dst *tar.Writer -} - -func (tc *tarCompress) Close() error { - // we have to wait here in case the actual content isn't copied completely into the - // writer yet - tc.wg.Wait() - - var err, err2 error - if tc.parent != nil { - err2 = tc.parent.Close() - } - err = tc.dst.Close() - - if err != nil && err2 != nil { - // best way to show double errors at once that I've found - return fmt.Errorf("%v\n%v", err, err2) - } else if err == nil && err2 != nil { - err = err2 - } - - return err -} - -func (tc *tarCompress) NewFile(information formatInformation) (io.WriteCloser, error) { - rp, wp := io.Pipe() - go func() { - tc.wg.Add(1) - defer tc.wg.Done() - var buf bytes.Buffer - io.Copy(&buf, rp) - - header := &tar.Header{ - Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title), - ModTime: time.Now(), - Mode: 0644, - Typeflag: tar.TypeReg, - // fun fact: I did not set the size for quiet some time because I thought that it isn't - // required. well because of this I debugged this part for multiple hours because without - // proper size information only a tiny amount gets copied into the tar (or zip) writer. - // this is also the reason why the file content is completely copied into a buffer before - // writing it to the writer. I could bypass this and save some memory but this requires - // some rewriting and im nearly at the (planned) finish for version 2 so nah in the future - // maybe - Size: int64(buf.Len()), - } - tc.dst.WriteHeader(header) - io.Copy(tc.dst, &buf) - }() - return wp, nil -} - -func newZipCompress(file *os.File) *zipCompress { - return &zipCompress{ - dst: zip.NewWriter(file), - } -} - -type zipCompress struct { - compress - - wg sync.WaitGroup - - dst *zip.Writer -} - -func (zc *zipCompress) Close() error { - zc.wg.Wait() - return zc.dst.Close() -} - -func (zc *zipCompress) NewFile(information formatInformation) (io.WriteCloser, error) { - rp, wp := io.Pipe() - go func() { - zc.wg.Add(1) - defer zc.wg.Done() - - var buf bytes.Buffer - io.Copy(&buf, rp) - - header := &zip.FileHeader{ - Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title), - Modified: time.Now(), - Method: zip.Deflate, - UncompressedSize64: uint64(buf.Len()), - } - header.SetMode(0644) - - hw, _ := zc.dst.CreateHeader(header) - io.Copy(hw, &buf) - }() - - return wp, nil -} diff --git a/cli/commands/archive/compress.go b/cli/commands/archive/compress.go new file mode 100644 index 0000000..d6aa492 --- /dev/null +++ b/cli/commands/archive/compress.go @@ -0,0 +1,136 @@ +package archive + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "github.com/ByteDream/crunchy-cli/utils" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +type Compress interface { + io.Closer + + NewFile(information utils.FormatInformation) (io.WriteCloser, error) +} + +func NewGzipCompress(file *os.File) *TarCompress { + gw := gzip.NewWriter(file) + return &TarCompress{ + parent: gw, + dst: tar.NewWriter(gw), + } +} + +func NewTarCompress(file *os.File) *TarCompress { + return &TarCompress{ + dst: tar.NewWriter(file), + } +} + +type TarCompress struct { + Compress + + wg sync.WaitGroup + + parent *gzip.Writer + dst *tar.Writer +} + +func (tc *TarCompress) Close() error { + // we have to wait here in case the actual content isn't copied completely into the + // writer yet + tc.wg.Wait() + + var err, err2 error + if tc.parent != nil { + err2 = tc.parent.Close() + } + err = tc.dst.Close() + + if err != nil && err2 != nil { + // best way to show double errors at once that I've found + return fmt.Errorf("%v\n%v", err, err2) + } else if err == nil && err2 != nil { + err = err2 + } + + return err +} + +func (tc *TarCompress) NewFile(information utils.FormatInformation) (io.WriteCloser, error) { + rp, wp := io.Pipe() + go func() { + tc.wg.Add(1) + defer tc.wg.Done() + var buf bytes.Buffer + io.Copy(&buf, rp) + + header := &tar.Header{ + Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title), + ModTime: time.Now(), + Mode: 0644, + Typeflag: tar.TypeReg, + // fun fact: I did not set the size for quiet some time because I thought that it isn't + // required. well because of this I debugged this part for multiple hours because without + // proper size information only a tiny amount gets copied into the tar (or zip) writer. + // this is also the reason why the file content is completely copied into a buffer before + // writing it to the writer. I could bypass this and save some memory but this requires + // some rewriting and im nearly at the (planned) finish for version 2 so nah in the future + // maybe + Size: int64(buf.Len()), + } + tc.dst.WriteHeader(header) + io.Copy(tc.dst, &buf) + }() + return wp, nil +} + +func NewZipCompress(file *os.File) *ZipCompress { + return &ZipCompress{ + dst: zip.NewWriter(file), + } +} + +type ZipCompress struct { + Compress + + wg sync.WaitGroup + + dst *zip.Writer +} + +func (zc *ZipCompress) Close() error { + zc.wg.Wait() + return zc.dst.Close() +} + +func (zc *ZipCompress) NewFile(information utils.FormatInformation) (io.WriteCloser, error) { + rp, wp := io.Pipe() + go func() { + zc.wg.Add(1) + defer zc.wg.Done() + + var buf bytes.Buffer + io.Copy(&buf, rp) + + header := &zip.FileHeader{ + Name: filepath.Join(fmt.Sprintf("S%2d", information.SeasonNumber), information.Title), + Modified: time.Now(), + Method: zip.Deflate, + UncompressedSize64: uint64(buf.Len()), + } + header.SetMode(0644) + + hw, _ := zc.dst.CreateHeader(header) + io.Copy(hw, &buf) + }() + + return wp, nil +} diff --git a/commands/download.go b/cli/commands/download/download.go similarity index 64% rename from commands/download.go rename to cli/commands/download/download.go index 82b68bf..4e0ef66 100644 --- a/commands/download.go +++ b/cli/commands/download/download.go @@ -1,10 +1,12 @@ -package commands +package download import ( "context" "fmt" + "github.com/ByteDream/crunchy-cli/cli/commands" + "github.com/ByteDream/crunchy-cli/utils" "github.com/ByteDream/crunchyroll-go/v3" - "github.com/ByteDream/crunchyroll-go/v3/utils" + crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils" "github.com/grafov/m3u8" "github.com/spf13/cobra" "math" @@ -29,29 +31,29 @@ var ( downloadGoroutinesFlag int ) -var downloadCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "download", Short: "Download a video", Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - out.Debug("Validating arguments") + utils.Log.Debug("Validating arguments") if filepath.Ext(downloadOutputFlag) != ".ts" { - if !hasFFmpeg() { + if !utils.HasFFmpeg() { return fmt.Errorf("the file ending for the output file (%s) is not `.ts`. "+ "Install ffmpeg (https://ffmpeg.org/download.html) to use other media file endings (e.g. `.mp4`)", downloadOutputFlag) } else { - out.Debug("Custom file ending '%s' (ffmpeg is installed)", filepath.Ext(downloadOutputFlag)) + utils.Log.Debug("Custom file ending '%s' (ffmpeg is installed)", filepath.Ext(downloadOutputFlag)) } } - if !utils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) { - return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(allLocalesAsStrings(), ", ")) - } else if downloadSubtitleFlag != "" && !utils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) { - return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(allLocalesAsStrings(), ", ")) + if !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadAudioFlag)) { + return fmt.Errorf("%s is not a valid audio locale. Choose from: %s", downloadAudioFlag, strings.Join(utils.LocalesAsStrings(), ", ")) + } else if downloadSubtitleFlag != "" && !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(downloadSubtitleFlag)) { + return fmt.Errorf("%s is not a valid subtitle locale. Choose from: %s", downloadSubtitleFlag, strings.Join(utils.LocalesAsStrings(), ", ")) } - out.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag) + utils.Log.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag) switch downloadResolutionFlag { case "1080p", "720p", "480p", "360p": @@ -64,35 +66,37 @@ var downloadCmd = &cobra.Command{ default: return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag) } - out.Debug("Using resolution '%s'", downloadResolutionFlag) + utils.Log.Debug("Using resolution '%s'", downloadResolutionFlag) return nil }, RunE: func(cmd *cobra.Command, args []string) error { - loadCrunchy() + if err := commands.LoadCrunchy(); err != nil { + return err + } return download(args) }, } func init() { - downloadCmd.Flags().StringVarP(&downloadAudioFlag, "audio", + Cmd.Flags().StringVarP(&downloadAudioFlag, "audio", "a", - string(systemLocale(false)), - "The locale of the audio. Available locales: "+strings.Join(allLocalesAsStrings(), ", ")) - downloadCmd.Flags().StringVarP(&downloadSubtitleFlag, + string(utils.SystemLocale(false)), + "The locale of the audio. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", ")) + Cmd.Flags().StringVarP(&downloadSubtitleFlag, "subtitle", "s", "", - "The locale of the subtitle. Available locales: "+strings.Join(allLocalesAsStrings(), ", ")) + "The locale of the subtitle. Available locales: "+strings.Join(utils.LocalesAsStrings(), ", ")) cwd, _ := os.Getwd() - downloadCmd.Flags().StringVarP(&downloadDirectoryFlag, + Cmd.Flags().StringVarP(&downloadDirectoryFlag, "directory", "d", cwd, "The directory to download the file(s) into") - downloadCmd.Flags().StringVarP(&downloadOutputFlag, + Cmd.Flags().StringVarP(&downloadOutputFlag, "output", "o", "{title}.ts", @@ -108,7 +112,7 @@ func init() { "\t{audio} » Audio locale of the video\n"+ "\t{subtitle} » Subtitle locale of the video") - downloadCmd.Flags().StringVarP(&downloadResolutionFlag, + Cmd.Flags().StringVarP(&downloadResolutionFlag, "resolution", "r", "best", @@ -117,34 +121,32 @@ func init() { "\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+ "\tAvailable common-use words: best (best available resolution), worst (worst available resolution)") - downloadCmd.Flags().IntVarP(&downloadGoroutinesFlag, + Cmd.Flags().IntVarP(&downloadGoroutinesFlag, "goroutines", "g", runtime.NumCPU(), "Sets how many parallel segment downloads should be used") - - rootCmd.AddCommand(downloadCmd) } func download(urls []string) error { for i, url := range urls { - out.SetProgress("Parsing url %d", i+1) + utils.Log.SetProcess("Parsing url %d", i+1) episodes, err := downloadExtractEpisodes(url) if err != nil { - out.StopProgress("Failed to parse url %d", i+1) - if crunchy.Config.Premium { - out.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " + + utils.Log.StopProcess("Failed to parse url %d", i+1) + if utils.Crunchy.Config.Premium { + utils.Log.Debug("If the error says no episodes could be found but the passed url is correct and a crunchyroll classic url, " + "try the corresponding crunchyroll beta url instead and try again. See https://github.com/ByteDream/crunchy-cli/issues/22 for more information") } return err } - out.StopProgress("Parsed url %d", i+1) + utils.Log.StopProcess("Parsed url %d", i+1) for _, season := range episodes { - out.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber) + utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber) for j, info := range season { - out.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)", + utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)", j+1, info.Title, info.Resolution, @@ -153,17 +155,17 @@ func download(urls []string) error { info.EpisodeNumber) } } - out.Empty() + utils.Log.Empty() for j, season := range episodes { for k, info := range season { - dir := info.Format(downloadDirectoryFlag) + dir := info.FormatString(downloadDirectoryFlag) if _, err = os.Stat(dir); os.IsNotExist(err) { if err = os.MkdirAll(dir, 0777); err != nil { return fmt.Errorf("error while creating directory: %v", err) } } - file, err := os.Create(generateFilename(info.Format(downloadOutputFlag), dir)) + file, err := os.Create(utils.GenerateFilename(info.FormatString(downloadOutputFlag), dir)) if err != nil { return fmt.Errorf("failed to create output file: %v", err) } @@ -176,7 +178,7 @@ func download(urls []string) error { file.Close() if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 { - out.Empty() + utils.Log.Empty() } } } @@ -184,23 +186,23 @@ func download(urls []string) error { return nil } -func downloadInfo(info formatInformation, file *os.File) error { - out.Debug("Entering season %d, episode %d", info.SeasonNumber, info.EpisodeNumber) +func downloadInfo(info utils.FormatInformation, file *os.File) error { + utils.Log.Debug("Entering season %d, episode %d", info.SeasonNumber, info.EpisodeNumber) - if err := info.format.InitVideo(); err != nil { + if err := info.Format.InitVideo(); err != nil { return fmt.Errorf("error while initializing the video: %v", err) } - dp := &downloadProgress{ - Prefix: out.InfoLog.Prefix(), + dp := &commands.DownloadProgress{ + Prefix: utils.Log.(*commands.Logger).InfoLog.Prefix(), Message: "Downloading video", // number of segments a video has +2 is for merging and the success message - Total: int(info.format.Video.Chunklist.Count()) + 2, - Dev: out.IsDev(), - Quiet: out.IsQuiet(), + Total: int(info.Format.Video.Chunklist.Count()) + 2, + Dev: utils.Log.IsDev(), + Quiet: utils.Log.(*commands.Logger).IsQuiet(), } - if out.IsDev() { - dp.Prefix = out.DebugLog.Prefix() + if utils.Log.IsDev() { + dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix() } defer func() { if dp.Total != dp.Current { @@ -217,7 +219,7 @@ func downloadInfo(info formatInformation, file *os.File) error { return nil } - if out.IsDev() { + if utils.Log.IsDev() { dp.UpdateMessage(fmt.Sprintf("Downloading %d/%d (%.2f%%) » %s", current, total, float32(current)/float32(total)*100, segment.URI), false) } else { dp.Update() @@ -228,7 +230,7 @@ func downloadInfo(info formatInformation, file *os.File) error { } return nil }) - if hasFFmpeg() { + if utils.HasFFmpeg() { downloader.FFmpegOpts = make([]string, 0) } @@ -238,8 +240,8 @@ func downloadInfo(info formatInformation, file *os.File) error { select { case <-sig: signal.Stop(sig) - out.Exit("Exiting... (may take a few seconds)") - out.Exit("To force exit press ctrl+c (again)") + utils.Log.Err("Exiting... (may take a few seconds)") + utils.Log.Err("To force exit press ctrl+c (again)") cancel() // os.Exit(1) is not called because an immediate exit after the cancel function does not let // the download process enough time to stop gratefully. A result of this is that the temporary @@ -248,38 +250,38 @@ func downloadInfo(info formatInformation, file *os.File) error { // this is just here to end the goroutine and prevent it from running forever without a reason } }() - out.Debug("Set up signal catcher") + utils.Log.Debug("Set up signal catcher") - out.Info("Downloading episode `%s` to `%s`", info.Title, filepath.Base(file.Name())) - out.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber) - out.Info("\tAudio: %s", info.Audio) - out.Info("\tSubtitle: %s", info.Subtitle) - out.Info("\tResolution: %spx", info.Resolution) - out.Info("\tFPS: %.2f", info.FPS) - if err := info.format.Download(downloader); err != nil { + utils.Log.Info("Downloading episode `%s` to `%s`", info.Title, filepath.Base(file.Name())) + utils.Log.Info("\tEpisode: S%02dE%02d", info.SeasonNumber, info.EpisodeNumber) + utils.Log.Info("\tAudio: %s", info.Audio) + utils.Log.Info("\tSubtitle: %s", info.Subtitle) + utils.Log.Info("\tResolution: %spx", info.Resolution) + utils.Log.Info("\tFPS: %.2f", info.FPS) + if err := info.Format.Download(downloader); err != nil { return fmt.Errorf("error while downloading: %v", err) } dp.UpdateMessage("Download finished", false) signal.Stop(sig) - out.Debug("Stopped signal catcher") + utils.Log.Debug("Stopped signal catcher") - out.Empty() + utils.Log.Empty() return nil } -func downloadExtractEpisodes(url string) ([][]formatInformation, error) { - episodes, err := extractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag)) +func downloadExtractEpisodes(url string) ([][]utils.FormatInformation, error) { + episodes, err := utils.ExtractEpisodes(url, crunchyroll.JP, crunchyroll.LOCALE(downloadAudioFlag)) if err != nil { return nil, err } japanese := episodes[0] custom := episodes[1] - sort.Sort(utils.EpisodesByNumber(japanese)) - sort.Sort(utils.EpisodesByNumber(custom)) + sort.Sort(crunchyUtils.EpisodesByNumber(japanese)) + sort.Sort(crunchyUtils.EpisodesByNumber(custom)) var errMessages []string @@ -303,25 +305,25 @@ func downloadExtractEpisodes(url string) ([][]formatInformation, error) { if len(errMessages) > 10 { for _, msg := range errMessages[:10] { - out.SetProgress(msg) + utils.Log.SetProcess(msg) } - out.SetProgress("... and %d more", len(errMessages)-10) + utils.Log.SetProcess("... and %d more", len(errMessages)-10) } else { for _, msg := range errMessages { - out.SetProgress(msg) + utils.Log.SetProcess(msg) } } - var infoFormat [][]formatInformation - for _, season := range utils.SortEpisodesBySeason(final) { - tmpFormatInformation := make([]formatInformation, 0) + var infoFormat [][]utils.FormatInformation + for _, season := range crunchyUtils.SortEpisodesBySeason(final) { + tmpFormatInformation := make([]utils.FormatInformation, 0) for _, episode := range season { format, err := episode.GetFormat(downloadResolutionFlag, crunchyroll.LOCALE(downloadSubtitleFlag), true) if err != nil { return nil, fmt.Errorf("error while receiving format for %s: %v", episode.Title, err) } - tmpFormatInformation = append(tmpFormatInformation, formatInformation{ - format: format, + tmpFormatInformation = append(tmpFormatInformation, utils.FormatInformation{ + Format: format, Title: episode.Title, SeriesName: episode.SeriesTitle, diff --git a/cli/commands/info/info.go b/cli/commands/info/info.go new file mode 100644 index 0000000..a9a6ef0 --- /dev/null +++ b/cli/commands/info/info.go @@ -0,0 +1,40 @@ +package info + +import ( + "fmt" + "github.com/ByteDream/crunchy-cli/cli/commands" + "github.com/ByteDream/crunchy-cli/utils" + crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "info", + Short: "Shows information about the logged in user", + Args: cobra.MinimumNArgs(0), + + RunE: func(cmd *cobra.Command, args []string) error { + if err := commands.LoadCrunchy(); err != nil { + return err + } + + return info() + }, +} + +func info() error { + account, err := utils.Crunchy.Account() + if err != nil { + return err + } + + fmt.Println("Username: ", account.Username) + fmt.Println("Email: ", account.Email) + fmt.Println("Premium: ", utils.Crunchy.Config.Premium) + fmt.Println("Interface language:", crunchyUtils.LocaleLanguage(account.PreferredCommunicationLanguage)) + fmt.Println("Subtitle language: ", crunchyUtils.LocaleLanguage(account.PreferredContentSubtitleLanguage)) + fmt.Println("Created: ", account.Created) + fmt.Println("Account ID: ", account.AccountID) + + return nil +} diff --git a/commands/logger.go b/cli/commands/logger.go similarity index 82% rename from commands/logger.go rename to cli/commands/logger.go index fab8515..2f3ae37 100644 --- a/commands/logger.go +++ b/cli/commands/logger.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "github.com/ByteDream/crunchy-cli/utils" "io" "log" "os" @@ -35,19 +36,7 @@ type progress struct { stop bool } -type logger struct { - DebugLog *log.Logger - InfoLog *log.Logger - ErrLog *log.Logger - - devView bool - - progress chan progress - done chan interface{} - lock sync.Mutex -} - -func newLogger(debug, info, err bool) *logger { +func NewLogger(debug, info, err bool) *Logger { initPrefixBecauseWindowsSucksBallsHard() debugLog, infoLog, errLog := log.New(io.Discard, prefix+" ", 0), log.New(io.Discard, prefix+" ", 0), log.New(io.Discard, prefix+" ", 0) @@ -68,7 +57,7 @@ func newLogger(debug, info, err bool) *logger { errLog = log.New(errLog.Writer(), "[err] ", 0) } - return &logger{ + return &Logger{ DebugLog: debugLog, InfoLog: infoLog, ErrLog: errLog, @@ -77,38 +66,52 @@ func newLogger(debug, info, err bool) *logger { } } -func (l *logger) IsDev() bool { +type Logger struct { + utils.Logger + + DebugLog *log.Logger + InfoLog *log.Logger + ErrLog *log.Logger + + devView bool + + progress chan progress + done chan interface{} + lock sync.Mutex +} + +func (l *Logger) IsDev() bool { return l.devView } -func (l *logger) IsQuiet() bool { +func (l *Logger) IsQuiet() bool { return l.DebugLog.Writer() == io.Discard && l.InfoLog.Writer() == io.Discard && l.ErrLog.Writer() == io.Discard } -func (l *logger) Debug(format string, v ...interface{}) { +func (l *Logger) Debug(format string, v ...interface{}) { l.DebugLog.Printf(format, v...) } -func (l *logger) Info(format string, v ...interface{}) { +func (l *Logger) Info(format string, v ...interface{}) { l.InfoLog.Printf(format, v...) } -func (l *logger) Err(format string, v ...interface{}) { +func (l *Logger) Warn(format string, v ...interface{}) { + l.Err(format, v...) +} + +func (l *Logger) Err(format string, v ...interface{}) { l.ErrLog.Printf(format, v...) } -func (l *logger) Exit(format string, v ...interface{}) { - fmt.Fprintln(l.ErrLog.Writer(), fmt.Sprintf(format, v...)) -} - -func (l *logger) Empty() { +func (l *Logger) Empty() { if !l.devView && l.InfoLog.Writer() != io.Discard { fmt.Println("") } } -func (l *logger) SetProgress(format string, v ...interface{}) { - if out.InfoLog.Writer() == io.Discard { +func (l *Logger) SetProcess(format string, v ...interface{}) { + if l.InfoLog.Writer() == io.Discard { return } else if l.devView { l.Debug(format, v...) @@ -175,8 +178,8 @@ func (l *logger) SetProgress(format string, v ...interface{}) { }() } -func (l *logger) StopProgress(format string, v ...interface{}) { - if out.InfoLog.Writer() == io.Discard { +func (l *Logger) StopProcess(format string, v ...interface{}) { + if l.InfoLog.Writer() == io.Discard { return } else if l.devView { l.Debug(format, v...) diff --git a/cli/commands/login/login.go b/cli/commands/login/login.go new file mode 100644 index 0000000..31155d8 --- /dev/null +++ b/cli/commands/login/login.go @@ -0,0 +1,158 @@ +package login + +import ( + "bytes" + "fmt" + "github.com/ByteDream/crunchy-cli/cli/commands" + "github.com/ByteDream/crunchy-cli/utils" + "github.com/ByteDream/crunchyroll-go/v3" + "github.com/spf13/cobra" + "os" +) + +var ( + loginPersistentFlag bool + loginEncryptFlag bool + + loginSessionIDFlag bool + loginEtpRtFlag bool +) + +var Cmd = &cobra.Command{ + Use: "login", + Short: "Login to crunchyroll", + Args: cobra.RangeArgs(1, 2), + + RunE: func(cmd *cobra.Command, args []string) error { + if loginSessionIDFlag { + return loginSessionID(args[0]) + } else if loginEtpRtFlag { + return loginEtpRt(args[0]) + } else { + return loginCredentials(args[0], args[1]) + } + }, +} + +func init() { + Cmd.Flags().BoolVar(&loginPersistentFlag, + "persistent", + false, + "If the given credential should be stored persistent") + Cmd.Flags().BoolVar(&loginEncryptFlag, + "encrypt", + false, + "Encrypt the given credentials (won't do anything if --session-id is given or --persistent is not given)") + + Cmd.Flags().BoolVar(&loginSessionIDFlag, + "session-id", + false, + "Use a session id to login instead of username and password") + Cmd.Flags().BoolVar(&loginEtpRtFlag, + "etp-rt", + false, + "Use a etp rt cookie to login instead of username and password") + + Cmd.MarkFlagsMutuallyExclusive("session-id", "etp-rt") +} + +func loginCredentials(user, password string) error { + utils.Log.Debug("Logging in via credentials") + c, err := crunchyroll.LoginWithCredentials(user, password, utils.SystemLocale(false), utils.Client) + if err != nil { + return err + } + + if loginPersistentFlag { + var passwd []byte + if loginEncryptFlag { + for { + fmt.Print("Enter password: ") + passwd, err = commands.ReadLineSilent() + if err != nil { + return err + } + fmt.Println() + + fmt.Print("Enter password again: ") + repasswd, err := commands.ReadLineSilent() + if err != nil { + return err + } + fmt.Println() + + if bytes.Equal(passwd, repasswd) { + break + } + fmt.Println("Passwords does not match, try again") + } + } + if err = utils.SaveCredentialsPersistent(user, password, passwd); err != nil { + return err + } + if !loginEncryptFlag { + utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive. " + + "To encrypt it, use the `--encrypt` flag") + } + } + if err = utils.SaveSession(c); err != nil { + return err + } + + if !loginPersistentFlag { + utils.Log.Info("Due to security reasons, you have to login again on the next reboot") + } + + return nil +} + +func loginSessionID(sessionID string) error { + utils.Log.Debug("Logging in via session id") + var c *crunchyroll.Crunchyroll + var err error + if c, err = crunchyroll.LoginWithSessionID(sessionID, utils.SystemLocale(false), utils.Client); err != nil { + return err + } + + if loginPersistentFlag { + if err = utils.SaveSessionPersistent(c); err != nil { + return err + } + utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive") + } + if err = utils.SaveSession(c); err != nil { + return err + } + + if !loginPersistentFlag { + utils.Log.Info("Due to security reasons, you have to login again on the next reboot") + } + + return nil +} + +func loginEtpRt(etpRt string) error { + utils.Log.Debug("Logging in via etp rt") + var c *crunchyroll.Crunchyroll + var err error + if c, err = crunchyroll.LoginWithEtpRt(etpRt, utils.SystemLocale(false), utils.Client); err != nil { + utils.Log.Err(err.Error()) + os.Exit(1) + } + + if loginPersistentFlag { + if err = utils.SaveSessionPersistent(c); err != nil { + return err + } + utils.Log.Warn("The login information will be stored permanently UNENCRYPTED on your drive") + } + if err = utils.SaveSession(c); err != nil { + return err + } + + if !loginPersistentFlag { + utils.Log.Info("Due to security reasons, you have to login again on the next reboot") + } + + return nil +} diff --git a/commands/unix.go b/cli/commands/unix.go similarity index 95% rename from commands/unix.go rename to cli/commands/unix.go index 955695c..ec69180 100644 --- a/commands/unix.go +++ b/cli/commands/unix.go @@ -19,7 +19,7 @@ func init() { } } -func readLineSilent() ([]byte, error) { +func ReadLineSilent() ([]byte, error) { pid, err := setEcho(false) if err != nil { return nil, err diff --git a/commands/update.go b/cli/commands/update/update.go similarity index 67% rename from commands/update.go rename to cli/commands/update/update.go index fb4c1e9..6587ca2 100644 --- a/commands/update.go +++ b/cli/commands/update/update.go @@ -1,8 +1,9 @@ -package commands +package update import ( "encoding/json" "fmt" + "github.com/ByteDream/crunchy-cli/utils" "github.com/spf13/cobra" "io" "os" @@ -15,7 +16,7 @@ var ( updateInstallFlag bool ) -var updateCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "update", Short: "Check if updates are available", Args: cobra.MaximumNArgs(0), @@ -26,19 +27,17 @@ var updateCmd = &cobra.Command{ } func init() { - updateCmd.Flags().BoolVarP(&updateInstallFlag, + Cmd.Flags().BoolVarP(&updateInstallFlag, "install", "i", false, "If set and a new version is available, the new version gets installed") - - rootCmd.AddCommand(updateCmd) } func update() error { var release map[string]interface{} - resp, err := client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest") + resp, err := utils.Client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest") if err != nil { return err } @@ -48,8 +47,8 @@ func update() error { } releaseVersion := strings.TrimPrefix(release["tag_name"].(string), "v") - if Version == "development" { - out.Info("Development version, update service not available") + if utils.Version == "development" { + utils.Log.Info("Development version, update service not available") return nil } @@ -58,17 +57,17 @@ func update() error { return fmt.Errorf("latest tag name (%s) is not parsable", releaseVersion) } - internalVersion := strings.SplitN(Version, ".", 4) + internalVersion := strings.SplitN(utils.Version, ".", 4) if len(internalVersion) != 3 { - return fmt.Errorf("internal version (%s) is not parsable", Version) + return fmt.Errorf("internal version (%s) is not parsable", utils.Version) } - out.Info("Installed version is %s", Version) + utils.Log.Info("Installed version is %s", utils.Version) var hasUpdate bool for i := 0; i < 3; i++ { if latestRelease[i] < internalVersion[i] { - out.Info("Local version is newer than version in latest release (%s)", releaseVersion) + utils.Log.Info("Local version is newer than version in latest release (%s)", releaseVersion) return nil } else if latestRelease[i] > internalVersion[i] { hasUpdate = true @@ -76,11 +75,11 @@ func update() error { } if !hasUpdate { - out.Info("Version is up-to-date") + utils.Log.Info("Version is up-to-date") return nil } - out.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion) + utils.Log.Info("A new version is available (%s): https://github.com/ByteDream/crunchy-cli/releases/tag/v%s", releaseVersion, releaseVersion) if updateInstallFlag { if runtime.GOARCH != "amd64" { @@ -93,7 +92,7 @@ func update() error { case "linux": yayCommand := exec.Command("pacman -Q crunchy-cli") if yayCommand.Run() == nil && yayCommand.ProcessState.Success() { - out.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended") + utils.Log.Info("crunchy-cli was probably installed via an Arch Linux AUR helper (like yay). Updating via this AUR helper is recommended") return nil } downloadFile = fmt.Sprintf("crunchy-v%s_linux", releaseVersion) @@ -106,7 +105,7 @@ func update() error { "You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS) } - out.SetProgress("Updating executable %s", os.Args[0]) + utils.Log.SetProcess("Updating executable %s", os.Args[0]) perms, err := os.Stat(os.Args[0]) if err != nil { @@ -119,7 +118,7 @@ func update() error { } defer executeFile.Close() - resp, err := client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile)) + resp, err := utils.Client.Get(fmt.Sprintf("https://github.com/ByteDream/crunchy-cli/releases/download/v%s/%s", releaseVersion, downloadFile)) if err != nil { return err } @@ -129,7 +128,7 @@ func update() error { return err } - out.StopProgress("Updated executable %s", os.Args[0]) + utils.Log.StopProcess("Updated executable %s", os.Args[0]) } return nil diff --git a/cli/commands/utils.go b/cli/commands/utils.go new file mode 100644 index 0000000..924cd4d --- /dev/null +++ b/cli/commands/utils.go @@ -0,0 +1,125 @@ +package commands + +import ( + "fmt" + "github.com/ByteDream/crunchy-cli/utils" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" +) + +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) +} + +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 LoadCrunchy() error { + var encryptionKey []byte + + if utils.IsTempSession() { + encryptionKey = nil + } else { + if encrypted, err := utils.IsSavedSessionEncrypted(); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("to use this command, login first. Type `%s login -h` to get help", os.Args[0]) + } + return err + } else if encrypted { + encryptionKey, err = ReadLineSilent() + if err != nil { + return fmt.Errorf("failed to read password") + } + } + } + + var err error + utils.Crunchy, err = utils.LoadSession(encryptionKey) + return err +} diff --git a/commands/windows.go b/cli/commands/windows.go similarity index 94% rename from commands/windows.go rename to cli/commands/windows.go index 2ae47b5..a9bce74 100644 --- a/commands/windows.go +++ b/cli/commands/windows.go @@ -9,7 +9,7 @@ import ( ) // https://github.com/bgentry/speakeasy/blob/master/speakeasy_windows.go -func readLineSilent() ([]byte, error) { +func ReadLineSilent() ([]byte, error) { var oldMode uint32 if err := syscall.GetConsoleMode(syscall.Stdin, &oldMode); err != nil { diff --git a/cli/root.go b/cli/root.go new file mode 100644 index 0000000..ebb5079 --- /dev/null +++ b/cli/root.go @@ -0,0 +1,85 @@ +package cli + +import ( + "context" + "fmt" + "github.com/ByteDream/crunchy-cli/cli/commands" + "github.com/ByteDream/crunchy-cli/cli/commands/archive" + "github.com/ByteDream/crunchy-cli/cli/commands/download" + "github.com/ByteDream/crunchy-cli/cli/commands/info" + "github.com/ByteDream/crunchy-cli/cli/commands/login" + "github.com/ByteDream/crunchy-cli/cli/commands/update" + "github.com/ByteDream/crunchy-cli/utils" + "github.com/spf13/cobra" + "os" + "runtime/debug" + "strings" +) + +var ( + quietFlag bool + verboseFlag bool + + proxyFlag string + + useragentFlag string +) + +var RootCmd = &cobra.Command{ + Use: "crunchy-cli", + Version: utils.Version, + Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki", + + SilenceErrors: true, + SilenceUsage: true, + + PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) { + if verboseFlag { + utils.Log = commands.NewLogger(true, true, true) + } else if quietFlag { + utils.Log = commands.NewLogger(false, false, false) + } + + utils.Log.Debug("Executing `%s` command with %d arg(s)", cmd.Name(), len(args)) + + utils.Client, err = utils.CreateOrDefaultClient(proxyFlag, useragentFlag) + 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().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", utils.Version), "Useragent to do all request with") + + RootCmd.AddCommand(archive.Cmd) + RootCmd.AddCommand(download.Cmd) + RootCmd.AddCommand(info.Cmd) + RootCmd.AddCommand(login.Cmd) + RootCmd.AddCommand(update.Cmd) + + utils.Log = commands.NewLogger(false, true, true) +} + +func Execute() { + RootCmd.CompletionOptions.HiddenDefaultCmd = true + defer func() { + if r := recover(); r != nil { + if utils.Log.IsDev() { + utils.Log.Err("%v: %s", r, debug.Stack()) + } else { + utils.Log.Err("Unexpected error: %v", r) + } + os.Exit(1) + } + }() + if err := RootCmd.Execute(); err != nil { + if !strings.HasSuffix(err.Error(), context.Canceled.Error()) { + utils.Log.Err("An error occurred: %v", err) + } + os.Exit(1) + } +} diff --git a/commands/info.go b/commands/info.go deleted file mode 100644 index fe9693d..0000000 --- a/commands/info.go +++ /dev/null @@ -1,40 +0,0 @@ -package commands - -import ( - "fmt" - "github.com/ByteDream/crunchyroll-go/v3/utils" - "github.com/spf13/cobra" -) - -var infoCmd = &cobra.Command{ - Use: "info", - Short: "Shows information about the logged in user", - Args: cobra.MinimumNArgs(0), - - RunE: func(cmd *cobra.Command, args []string) error { - loadCrunchy() - - return info() - }, -} - -func init() { - rootCmd.AddCommand(infoCmd) -} - -func info() error { - account, err := crunchy.Account() - if err != nil { - return err - } - - fmt.Println("Username: ", account.Username) - fmt.Println("Email: ", account.Email) - fmt.Println("Premium: ", crunchy.Config.Premium) - fmt.Println("Interface language:", utils.LocaleLanguage(account.PreferredCommunicationLanguage)) - fmt.Println("Subtitle language: ", utils.LocaleLanguage(account.PreferredContentSubtitleLanguage)) - fmt.Println("Created: ", account.Created) - fmt.Println("Account ID: ", account.AccountID) - - return nil -} diff --git a/commands/login.go b/commands/login.go deleted file mode 100644 index a710bb6..0000000 --- a/commands/login.go +++ /dev/null @@ -1,206 +0,0 @@ -package commands - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "fmt" - "github.com/ByteDream/crunchyroll-go/v3" - "github.com/spf13/cobra" - "io" - "os" - "path/filepath" -) - -var ( - loginPersistentFlag bool - loginEncryptFlag bool - - loginSessionIDFlag bool - loginEtpRtFlag 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 loginSessionIDFlag { - return loginSessionID(args[0]) - } else if loginEtpRtFlag { - return loginEtpRt(args[0]) - } else { - return loginCredentials(args[0], args[1]) - } - }, -} - -func init() { - loginCmd.Flags().BoolVar(&loginPersistentFlag, - "persistent", - false, - "If the given credential should be stored persistent") - loginCmd.Flags().BoolVar(&loginEncryptFlag, - "encrypt", - false, - "Encrypt the given credentials (won't do anything if --session-id is given or --persistent is not given)") - - loginCmd.Flags().BoolVar(&loginSessionIDFlag, - "session-id", - false, - "Use a session id to login instead of username and password") - loginCmd.Flags().BoolVar(&loginEtpRtFlag, - "etp-rt", - false, - "Use a etp rt cookie to login instead of username and password") - - rootCmd.AddCommand(loginCmd) -} - -func loginCredentials(user, password string) error { - out.Debug("Logging in via credentials") - c, err := crunchyroll.LoginWithCredentials(user, password, systemLocale(false), client) - if err != nil { - return err - } - - if loginPersistentFlag { - if configDir, err := os.UserConfigDir(); err != nil { - return fmt.Errorf("could not save credentials persistent: %w", err) - } else { - var credentials []byte - - if loginEncryptFlag { - var passwd []byte - - for { - fmt.Print("Enter password: ") - passwd, err = readLineSilent() - if err != nil { - return err - } - fmt.Println() - - fmt.Print("Enter password again: ") - repasswd, err := readLineSilent() - if err != nil { - return err - } - fmt.Println() - - if !bytes.Equal(passwd, repasswd) { - fmt.Println("Passwords does not match, try again") - continue - } - - 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 := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - out.Err("Failed to fill nonce: %w", err) - os.Exit(1) - } - - b := gcm.Seal(nonce, nonce, []byte(fmt.Sprintf("%s\n%s", user, password)), nil) - credentials = append([]byte("aes:"), b...) - - break - } - } else { - credentials = []byte(fmt.Sprintf("%s\n%s", user, password)) - } - - os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755) - if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), credentials, 0600); err != nil { - return err - } - if !loginEncryptFlag { - out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s). "+ - "To encrypt it, use the `--encrypt` flag", filepath.Join(configDir, "crunchy-cli", "crunchy")) - } - } - } - - if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil { - return err - } - - if !loginPersistentFlag { - out.Info("Due to security reasons, you have to login again on the next reboot") - } - - return nil -} - -func loginSessionID(sessionID string) error { - out.Debug("Logging in via session id") - var c *crunchyroll.Crunchyroll - var err error - if c, err = crunchyroll.LoginWithSessionID(sessionID, systemLocale(false), client); err != nil { - out.Err(err.Error()) - os.Exit(1) - } - - if loginPersistentFlag { - if configDir, err := os.UserConfigDir(); err != nil { - return fmt.Errorf("could not save credentials persistent: %w", err) - } else { - os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755) - if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(c.EtpRt), 0600); err != nil { - return err - } - out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy")) - } - } - if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(c.EtpRt), 0600); err != nil { - return err - } - - if !loginPersistentFlag { - out.Info("Due to security reasons, you have to login again on the next reboot") - } - - return nil -} - -func loginEtpRt(etpRt string) error { - out.Debug("Logging in via etp rt") - if _, err := crunchyroll.LoginWithEtpRt(etpRt, systemLocale(false), client); err != nil { - out.Err(err.Error()) - os.Exit(1) - } - - var err error - if loginPersistentFlag { - if configDir, err := os.UserConfigDir(); err != nil { - return fmt.Errorf("could not save credentials persistent: %w", err) - } else { - os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755) - if err = os.WriteFile(filepath.Join(configDir, "crunchy-cli", "crunchy"), []byte(etpRt), 0600); err != nil { - return err - } - out.Info("The login information will be stored permanently UNENCRYPTED on your drive (%s)", filepath.Join(configDir, "crunchy-cli", "crunchy")) - } - } - if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(etpRt), 0600); err != nil { - return err - } - - if !loginPersistentFlag { - out.Info("Due to security reasons, you have to login again on the next reboot") - } - - return nil -} diff --git a/commands/root.go b/commands/root.go deleted file mode 100644 index ffcf35a..0000000 --- a/commands/root.go +++ /dev/null @@ -1,78 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "github.com/ByteDream/crunchyroll-go/v3" - "github.com/spf13/cobra" - "net/http" - "os" - "runtime/debug" - "strings" -) - -var Version = "development" - -var ( - client *http.Client - crunchy *crunchyroll.Crunchyroll - out = newLogger(false, true, true) - - quietFlag bool - verboseFlag bool - - proxyFlag string - - useragentFlag string -) - -var rootCmd = &cobra.Command{ - Use: "crunchy-cli", - Version: Version, - Short: "Download crunchyroll videos with ease. See the wiki for details about the cli and library: https://github.com/ByteDream/crunchy-cli/wiki", - - SilenceErrors: true, - SilenceUsage: true, - - PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) { - if verboseFlag { - out = newLogger(true, true, true) - } else if quietFlag { - out = newLogger(false, false, false) - } - - out.Debug("Executing `%s` command with %d arg(s)", cmd.Name(), len(args)) - - client, err = createOrDefaultClient(proxyFlag, useragentFlag) - 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().StringVar(&useragentFlag, "useragent", fmt.Sprintf("crunchy-cli/%s", Version), "Useragent to do all request with") -} - -func Execute() { - rootCmd.CompletionOptions.HiddenDefaultCmd = true - defer func() { - if r := recover(); r != nil { - if out.IsDev() { - out.Err("%v: %s", r, debug.Stack()) - } else { - out.Err("Unexpected error: %v", r) - } - os.Exit(1) - } - }() - if err := rootCmd.Execute(); err != nil { - if !strings.HasSuffix(err.Error(), context.Canceled.Error()) { - out.Exit("An error occurred: %v", err) - } - os.Exit(1) - } -} diff --git a/commands/utils.go b/commands/utils.go deleted file mode 100644 index 486c61f..0000000 --- a/commands/utils.go +++ /dev/null @@ -1,485 +0,0 @@ -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) -} diff --git a/crunchy-cli.1 b/crunchy-cli.1 index d9b1075..ebadb9c 100644 --- a/crunchy-cli.1 +++ b/crunchy-cli.1 @@ -61,7 +61,7 @@ NOTE: The credentials are stored in plain text and if you not use \fB--session-i Login via a session id (which can be extracted from a crunchyroll browser cookie) instead of using username and password. .SH DOWNLOAD COMMAND -A command to simply download videos. The output file is stored as a \fI.ts\fR file. \fIffmpeg\fR has to be installed if you want to change the format the videos are stored in. +A command to simply download videos. The output file is stored as a \fI.ts\fR file. \fIffmpeg\fR has to be installed if you want to change the Format the videos are stored in. .TP \fB-a, --audio AUDIO\fR diff --git a/go.mod b/go.mod index 2c0abc0..909378c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/ByteDream/crunchy-cli go 1.18 require ( - github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c + github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1 github.com/grafov/m3u8 v0.11.1 github.com/spf13/cobra v1.5.0 ) diff --git a/go.sum b/go.sum index 626a2c5..5603301 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c h1:jPabd/Zl/zdoSo8ZGtZLm43+42nIFHIJABvrvdMOYtY= github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220627201246-98185d763c0c/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4= +github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1 h1:hOL4xzDc6oCcrpf6GrrdUgvqwsQo6dI2zL4nA8rl9hg= +github.com/ByteDream/crunchyroll-go/v3 v3.0.0-20220630135625-ed58b3fe8cc1/go.mod h1:L4M1sOPjJ4ui0YXFnpVUb4AzQIa+D/i/B0QG5iz9op4= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= diff --git a/main.go b/main.go index f9c3a90..7275a13 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,9 @@ package main -import "github.com/ByteDream/crunchy-cli/commands" +import ( + "github.com/ByteDream/crunchy-cli/cli" +) func main() { - commands.Execute() + cli.Execute() } diff --git a/utils/extract.go b/utils/extract.go new file mode 100644 index 0000000..ba52242 --- /dev/null +++ b/utils/extract.go @@ -0,0 +1,94 @@ +package utils + +import ( + "fmt" + "github.com/ByteDream/crunchyroll-go/v3" + "github.com/ByteDream/crunchyroll-go/v3/utils" + "regexp" + "strconv" + "strings" +) + +var urlFilter = regexp.MustCompile(`(S(\d+))?(E(\d+))?((-)(S(\d+))?(E(\d+))?)?(,|$)`) + +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 +} diff --git a/utils/file.go b/utils/file.go new file mode 100644 index 0000000..183b06f --- /dev/null +++ b/utils/file.go @@ -0,0 +1,49 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +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 GenerateFilename(name, directory string) string { + if runtime.GOOS != "windows" { + for _, char := range []string{"/"} { + name = strings.ReplaceAll(name, char, "") + } + Log.Debug("Replaced invalid characters (not windows)") + } else { + // ahh i love windows :))) + for _, char := range []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"} { + name = strings.ReplaceAll(name, char, "") + } + Log.Debug("Replaced invalid characters (windows)") + } + + filename, changed := FreeFileName(filepath.Join(directory, name)) + if changed { + Log.Debug("File `%s` already exists, changing name to `%s`", filepath.Base(name), filepath.Base(filename)) + } + + return filename +} diff --git a/utils/format.go b/utils/format.go new file mode 100644 index 0000000..9642a31 --- /dev/null +++ b/utils/format.go @@ -0,0 +1,63 @@ +package utils + +import ( + "fmt" + "github.com/ByteDream/crunchyroll-go/v3" + "reflect" + "runtime" + "strings" +) + +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) FormatString(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 []string{"/"} { + valueAsString = strings.ReplaceAll(valueAsString, char, "") + } + } else { + for _, char := range []string{"\\", "<", ">", ":", "\"", "/", "|", "?", "*"} { + valueAsString = strings.ReplaceAll(valueAsString, char, "") + } + } + + source = strings.ReplaceAll(source, "{"+fields.Field(i).Tag.Get("json")+"}", valueAsString) + } + + return source +} diff --git a/utils/http.go b/utils/http.go new file mode 100644 index 0000000..f572bd9 --- /dev/null +++ b/utils/http.go @@ -0,0 +1,51 @@ +package utils + +import ( + "net/http" + "net/url" + "time" +) + +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 { + 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 + } +} diff --git a/utils/locale.go b/utils/locale.go new file mode 100644 index 0000000..e1517bb --- /dev/null +++ b/utils/locale.go @@ -0,0 +1,59 @@ +package utils + +import ( + "fmt" + "github.com/ByteDream/crunchyroll-go/v3" + "github.com/ByteDream/crunchyroll-go/v3/utils" + "os" + "os/exec" + "runtime" + "sort" + "strings" +) + +// 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 { + Log.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 { + Log.Err("%s is not a supported locale, using %s as fallback", l, crunchyroll.US) + } + l = crunchyroll.US + } + return l + } + } + if verbose { + Log.Err("Failed to get locale, using %s", crunchyroll.US) + } + return crunchyroll.US +} + +func LocalesAsStrings() (locales []string) { + for _, locale := range utils.AllLocales { + locales = append(locales, string(locale)) + } + sort.Strings(locales) + return +} diff --git a/utils/logger.go b/utils/logger.go new file mode 100644 index 0000000..27ea344 --- /dev/null +++ b/utils/logger.go @@ -0,0 +1,12 @@ +package utils + +type Logger interface { + IsDev() bool + Debug(format string, v ...any) + Info(format string, v ...any) + Warn(format string, v ...any) + Err(format string, v ...any) + Empty() + SetProcess(format string, v ...any) + StopProcess(format string, v ...any) +} diff --git a/utils/save.go b/utils/save.go new file mode 100644 index 0000000..43eeda7 --- /dev/null +++ b/utils/save.go @@ -0,0 +1,177 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "github.com/ByteDream/crunchyroll-go/v3" + "io" + "os" + "path/filepath" + "strings" +) + +func SaveSession(crunchy *crunchyroll.Crunchyroll) error { + file := filepath.Join(os.TempDir(), ".crunchy") + return os.WriteFile(file, []byte(crunchy.EtpRt), 0600) +} + +func SaveCredentialsPersistent(user, password string, encryptionKey []byte) error { + configDir, err := os.UserConfigDir() + if err != nil { + return err + } + file := filepath.Join(configDir, "crunchy-cli", "crunchy") + + var credentials []byte + if encryptionKey != nil { + hashedEncryptionKey := sha256.Sum256(encryptionKey) + block, err := aes.NewCipher(hashedEncryptionKey[:]) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + b := gcm.Seal(nonce, nonce, []byte(fmt.Sprintf("%s\n%s", user, password)), nil) + credentials = append([]byte("aes:"), b...) + } else { + credentials = []byte(fmt.Sprintf("%s\n%s", user, password)) + } + + if err = os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755); err != nil { + return err + } + return os.WriteFile(file, credentials, 0600) +} + +func SaveSessionPersistent(crunchy *crunchyroll.Crunchyroll) error { + configDir, err := os.UserConfigDir() + if err != nil { + return err + } + file := filepath.Join(configDir, "crunchy-cli", "crunchy") + + if err = os.MkdirAll(filepath.Join(configDir, "crunchy-cli"), 0755); err != nil { + return err + } + return os.WriteFile(file, []byte(crunchy.EtpRt), 0600) +} + +func IsTempSession() bool { + file := filepath.Join(os.TempDir(), ".crunchy") + if _, err := os.Stat(file); !os.IsNotExist(err) { + return true + } + return false +} + +func IsSavedSessionEncrypted() (bool, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return false, err + } + file := filepath.Join(configDir, "crunchy-cli", "crunchy") + body, err := os.ReadFile(file) + if err != nil { + return false, err + } + return strings.HasPrefix(string(body), "aes:"), nil +} + +func LoadSession(encryptionKey []byte) (*crunchyroll.Crunchyroll, error) { + file := filepath.Join(os.TempDir(), ".crunchy") + crunchy, err := loadTempSession(file) + if err != nil { + return nil, err + } + if crunchy != nil { + return crunchy, nil + } + + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + file = filepath.Join(configDir, "crunchy-cli", "crunchy") + crunchy, err = loadPersistentSession(file, encryptionKey) + if err != nil { + return nil, err + } + if crunchy != nil { + return crunchy, nil + } + + return nil, fmt.Errorf("not logged in") +} + +func loadTempSession(file string) (*crunchyroll.Crunchyroll, error) { + if _, err := os.Stat(file); !os.IsNotExist(err) { + body, err := os.ReadFile(file) + if err != nil { + return nil, err + } + crunchy, err := crunchyroll.LoginWithEtpRt(string(body), SystemLocale(true), Client) + if err != nil { + Log.Debug("Failed to login with temp etp rt cookie: %v", err) + } else { + Log.Debug("Logged in with etp rt cookie %s. BLANK THIS LINE OUT IF YOU'RE ASKED TO POST THE DEBUG OUTPUT SOMEWHERE", body) + return crunchy, nil + } + } + return nil, nil +} + +func loadPersistentSession(file string, encryptionKey []byte) (crunchy *crunchyroll.Crunchyroll, err error) { + if _, err = os.Stat(file); !os.IsNotExist(err) { + body, err := os.ReadFile(file) + if err != nil { + return nil, err + } + split := strings.SplitN(string(body), "\n", 2) + if len(split) == 1 || split[1] == "" && strings.HasPrefix(split[0], "aes:") { + encrypted := body[4:] + hashedEncryptionKey := sha256.Sum256(encryptionKey) + block, err := aes.NewCipher(hashedEncryptionKey[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce, cypherText := encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():] + b, err := gcm.Open(nil, nonce, cypherText, nil) + if err != nil { + return nil, err + } + 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 { + return nil, err + } + Log.Debug("Logged in with credentials") + } else { + if crunchy, err = crunchyroll.LoginWithEtpRt(split[0], SystemLocale(true), Client); err != nil { + return nil, err + } + Log.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 + if err = os.WriteFile(filepath.Join(os.TempDir(), ".crunchy"), []byte(crunchy.EtpRt), 0600); err != nil { + return nil, err + } + } + + return +} diff --git a/utils/system.go b/utils/system.go new file mode 100644 index 0000000..ac97706 --- /dev/null +++ b/utils/system.go @@ -0,0 +1,7 @@ +package utils + +import "os/exec" + +func HasFFmpeg() bool { + return exec.Command("ffmpeg", "-h").Run() == nil +} diff --git a/utils/vars.go b/utils/vars.go new file mode 100644 index 0000000..caca63e --- /dev/null +++ b/utils/vars.go @@ -0,0 +1,14 @@ +package utils + +import ( + "github.com/ByteDream/crunchyroll-go/v3" + "net/http" +) + +var Version = "development" + +var ( + Crunchy *crunchyroll.Crunchyroll + Client *http.Client + Log Logger +)