mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 12:12:00 -06:00
Move and refactor files and some more changes :3
This commit is contained in:
parent
781e520591
commit
303689ecbb
29 changed files with 1305 additions and 1160 deletions
695
cli/commands/archive/archive.go
Normal file
695
cli/commands/archive/archive.go
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/ByteDream/crunchy-cli/cli/commands"
|
||||
"github.com/ByteDream/crunchy-cli/utils"
|
||||
"github.com/ByteDream/crunchyroll-go/v3"
|
||||
crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
|
||||
"github.com/grafov/m3u8"
|
||||
"github.com/spf13/cobra"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
archiveLanguagesFlag []string
|
||||
|
||||
archiveDirectoryFlag string
|
||||
archiveOutputFlag string
|
||||
|
||||
archiveMergeFlag string
|
||||
|
||||
archiveCompressFlag string
|
||||
|
||||
archiveResolutionFlag string
|
||||
|
||||
archiveGoroutinesFlag int
|
||||
)
|
||||
|
||||
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 {
|
||||
utils.Log.Debug("Validating arguments")
|
||||
|
||||
if !utils.HasFFmpeg() {
|
||||
return fmt.Errorf("ffmpeg is needed to run this command correctly")
|
||||
}
|
||||
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 !crunchyUtils.ValidateLocale(crunchyroll.LOCALE(locale)) {
|
||||
// if locale is 'all', match all known locales
|
||||
if locale == "all" {
|
||||
archiveLanguagesFlag = utils.LocalesAsStrings()
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(utils.LocalesAsStrings(), ", "))
|
||||
}
|
||||
}
|
||||
utils.Log.Debug("Using following audio locales: %s", strings.Join(archiveLanguagesFlag, ", "))
|
||||
|
||||
var found bool
|
||||
for _, mode := range []string{"auto", "audio", "video"} {
|
||||
if archiveMergeFlag == mode {
|
||||
utils.Log.Debug("Using %s merge behavior", archiveMergeFlag)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("'%s' is no valid merge flag. Use 'auto', 'audio' or 'video'", archiveMergeFlag)
|
||||
}
|
||||
|
||||
if archiveCompressFlag != "" {
|
||||
found = false
|
||||
for _, algo := range []string{".tar", ".tar.gz", ".tgz", ".zip"} {
|
||||
if strings.HasSuffix(archiveCompressFlag, algo) {
|
||||
utils.Log.Debug("Using %s compression", algo)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("'%s' is no valid compress algorithm. Valid algorithms / file endings are '.tar', '.tar.gz', '.zip'",
|
||||
archiveCompressFlag)
|
||||
}
|
||||
}
|
||||
|
||||
switch archiveResolutionFlag {
|
||||
case "1080p", "720p", "480p", "360p":
|
||||
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"
|
||||
case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst":
|
||||
default:
|
||||
return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag)
|
||||
}
|
||||
utils.Log.Debug("Using resolution '%s'", archiveResolutionFlag)
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := commands.LoadCrunchy(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return archive(args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringSliceVarP(&archiveLanguagesFlag,
|
||||
"language",
|
||||
"l",
|
||||
[]string{string(utils.SystemLocale(false)), string(crunchyroll.JP)},
|
||||
"Audio locale which should be downloaded. Can be used multiple times")
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
Cmd.Flags().StringVarP(&archiveDirectoryFlag,
|
||||
"directory",
|
||||
"d",
|
||||
cwd,
|
||||
"The directory to store the files into")
|
||||
Cmd.Flags().StringVarP(&archiveOutputFlag,
|
||||
"output",
|
||||
"o",
|
||||
"{title}.mkv",
|
||||
"Name of the output file. If you use the following things in the name, the will get replaced:\n"+
|
||||
"\t{title} » Title of the video\n"+
|
||||
"\t{series_name} » Name of the series\n"+
|
||||
"\t{season_name} » Name of the season\n"+
|
||||
"\t{season_number} » Number of the season\n"+
|
||||
"\t{episode_number} » Number of the episode\n"+
|
||||
"\t{resolution} » Resolution of the video\n"+
|
||||
"\t{fps} » Frame Rate of the video\n"+
|
||||
"\t{audio} » Audio locale of the video\n"+
|
||||
"\t{subtitle} » Subtitle locale of the video")
|
||||
|
||||
Cmd.Flags().StringVarP(&archiveMergeFlag,
|
||||
"merge",
|
||||
"m",
|
||||
"auto",
|
||||
"Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'")
|
||||
|
||||
Cmd.Flags().StringVarP(&archiveCompressFlag,
|
||||
"compress",
|
||||
"c",
|
||||
"",
|
||||
"If is set, all output will be compresses into an archive (every url generates a new one). "+
|
||||
"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")
|
||||
|
||||
Cmd.Flags().StringVarP(&archiveResolutionFlag,
|
||||
"resolution",
|
||||
"r",
|
||||
"best",
|
||||
"The video resolution. Can either be specified via the pixels, the abbreviation for pixels, or 'common-use' words\n"+
|
||||
"\tAvailable pixels: 1920x1080, 1280x720, 640x480, 480x360, 428x240\n"+
|
||||
"\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+
|
||||
"\tAvailable common-use words: best (best available resolution), worst (worst available resolution)")
|
||||
|
||||
Cmd.Flags().IntVarP(&archiveGoroutinesFlag,
|
||||
"goroutines",
|
||||
"g",
|
||||
runtime.NumCPU(),
|
||||
"Number of parallel segment downloads")
|
||||
}
|
||||
|
||||
func archive(urls []string) error {
|
||||
for i, url := range urls {
|
||||
utils.Log.SetProcess("Parsing url %d", i+1)
|
||||
episodes, err := archiveExtractEpisodes(url)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
utils.Log.StopProcess("Parsed url %d", i+1)
|
||||
|
||||
var compressFile *os.File
|
||||
var c Compress
|
||||
|
||||
if 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)
|
||||
} else if strings.HasSuffix(archiveCompressFlag, ".tar.gz") || strings.HasSuffix(archiveCompressFlag, ".tgz") {
|
||||
c = NewGzipCompress(compressFile)
|
||||
} else if strings.HasSuffix(archiveCompressFlag, ".zip") {
|
||||
c = NewZipCompress(compressFile)
|
||||
}
|
||||
}
|
||||
|
||||
for _, season := range episodes {
|
||||
utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
|
||||
|
||||
for j, info := range season {
|
||||
utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
|
||||
j+1,
|
||||
info.Title,
|
||||
info.Resolution,
|
||||
info.FPS,
|
||||
info.SeasonNumber,
|
||||
info.EpisodeNumber)
|
||||
}
|
||||
}
|
||||
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.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.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 = utils.GenerateFilename(info.FormatString(archiveOutputFlag), dir)
|
||||
writeCloser, err = os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = archiveInfo(info, writeCloser, filename); err != nil {
|
||||
writeCloser.Close()
|
||||
if f, ok := writeCloser.(*os.File); ok {
|
||||
os.Remove(f.Name())
|
||||
} else {
|
||||
c.Close()
|
||||
compressFile.Close()
|
||||
os.RemoveAll(compressFile.Name())
|
||||
}
|
||||
return err
|
||||
}
|
||||
writeCloser.Close()
|
||||
|
||||
if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 {
|
||||
utils.Log.Empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
if compressFile != nil {
|
||||
compressFile.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("error while setting up downloader: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if dp.Total != dp.Current {
|
||||
fmt.Println()
|
||||
}
|
||||
}()
|
||||
|
||||
rootFile, err := os.CreateTemp("", fmt.Sprintf("%s_*.ts", strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(rootFile.Name())
|
||||
defer rootFile.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
downloader := crunchyroll.NewDownloader(ctx, rootFile, archiveGoroutinesFlag, func(segment *m3u8.MediaSegment, current, total int, file *os.File) error {
|
||||
// check if the context was cancelled.
|
||||
// must be done in to not print any progress messages if ctrl+c was pressed
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if current == total {
|
||||
dp.UpdateMessage("Merging segments", false)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
go func() {
|
||||
select {
|
||||
case <-sig:
|
||||
signal.Stop(sig)
|
||||
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
|
||||
// directory where the segments are downloaded to will not be deleted
|
||||
case <-ctx.Done():
|
||||
// this is just here to end the goroutine and prevent it from running forever without a reason
|
||||
}
|
||||
}()
|
||||
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 {
|
||||
// revoke the changed FFmpegOpts above
|
||||
additionalDownloaderOpts = []string{}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(additionalDownloaderOpts) > 0 {
|
||||
mergeMessage = "merging audio for additional formats"
|
||||
} else {
|
||||
mergeMessage = "merging video for additional formats"
|
||||
}
|
||||
case "audio":
|
||||
additionalDownloaderOpts = []string{"-vn"}
|
||||
mergeMessage = "merging audio for additional formats"
|
||||
case "video":
|
||||
mergeMessage = "merging video for additional formats"
|
||||
}
|
||||
|
||||
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() {
|
||||
for _, f := range append(append(videoFiles, audioFiles...), subtitleFiles...) {
|
||||
os.RemoveAll(f)
|
||||
}
|
||||
}()
|
||||
|
||||
var f []string
|
||||
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)
|
||||
}
|
||||
return err
|
||||
}
|
||||
videoFiles = append(videoFiles, f[0])
|
||||
|
||||
if len(additionalDownloaderOpts) == 0 {
|
||||
var videos []string
|
||||
downloader.FFmpegOpts = additionalDownloaderOpts
|
||||
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 {
|
||||
return fmt.Errorf("error while downloading additional videos: %v", err)
|
||||
}
|
||||
audioFiles = append(audioFiles, audios...)
|
||||
}
|
||||
|
||||
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 {
|
||||
if subtitle.Locale == crunchyroll.LOCALE(language) {
|
||||
info.Format.Subtitles = append([]*crunchyroll.Subtitle{subtitle}, append(info.Format.Subtitles[:i], info.Format.Subtitles[i+1:]...)...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subtitles []string
|
||||
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...)
|
||||
|
||||
if err = archiveFFmpeg(ctx, writeCloser, videoFiles, audioFiles, subtitleFiles); err != nil {
|
||||
return fmt.Errorf("failed to merge files: %v", err)
|
||||
}
|
||||
|
||||
dp.UpdateMessage("Download finished", false)
|
||||
|
||||
signal.Stop(sig)
|
||||
utils.Log.Debug("Stopped signal catcher")
|
||||
|
||||
utils.Log.Empty()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createArchiveProgress(info utils.FormatInformation) (*commands.DownloadProgress, error) {
|
||||
var progressCount int
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := f.InitVideo(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// + number of segments a video has +1 is for merging
|
||||
progressCount += int(f.Video.Chunklist.Count()) + 1
|
||||
}
|
||||
|
||||
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: utils.Log.IsDev(),
|
||||
Quiet: utils.Log.(*commands.Logger).IsQuiet(),
|
||||
}
|
||||
if utils.Log.IsDev() {
|
||||
dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix()
|
||||
}
|
||||
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
func archiveDownloadVideos(downloader crunchyroll.Downloader, filename string, video bool, formats ...*crunchyroll.Format) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
for _, format := range formats {
|
||||
var name string
|
||||
if video {
|
||||
name = fmt.Sprintf("%s_%s_video_*.ts", filename, format.AudioLocale)
|
||||
} else {
|
||||
name = fmt.Sprintf("%s_%s_audio_*.aac", filename, format.AudioLocale)
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f.Name())
|
||||
|
||||
downloader.Writer = f
|
||||
if err = format.Download(downloader); err != nil {
|
||||
f.Close()
|
||||
for _, file := range files {
|
||||
os.Remove(file)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
f.Close()
|
||||
|
||||
utils.Log.Debug("Downloaded '%s' video", format.AudioLocale)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func archiveDownloadSubtitles(filename string, subtitles ...*crunchyroll.Subtitle) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
for _, subtitle := range subtitles {
|
||||
f, err := os.CreateTemp("", fmt.Sprintf("%s_%s_subtitle_*.ass", filename, subtitle.Locale))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f.Name())
|
||||
|
||||
if err := subtitle.Save(f); err != nil {
|
||||
f.Close()
|
||||
for _, file := range files {
|
||||
os.Remove(file)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
f.Close()
|
||||
|
||||
utils.Log.Debug("Downloaded '%s' subtitles", subtitle.Locale)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func archiveFFmpeg(ctx context.Context, dst io.Writer, videoFiles, audioFiles, subtitleFiles []string) error {
|
||||
var input, maps, metadata []string
|
||||
re := regexp.MustCompile(`(?m)_([a-z]{2}-([A-Z]{2}|[0-9]{3}))_(video|audio|subtitle)`)
|
||||
|
||||
for i, video := range videoFiles {
|
||||
input = append(input, "-i", video)
|
||||
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", 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", crunchyUtils.LocaleLanguage(locale)))
|
||||
}
|
||||
|
||||
for i, audio := range audioFiles {
|
||||
input = append(input, "-i", audio)
|
||||
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", crunchyUtils.LocaleLanguage(locale)))
|
||||
}
|
||||
|
||||
for i, subtitle := range subtitleFiles {
|
||||
input = append(input, "-i", subtitle)
|
||||
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", crunchyUtils.LocaleLanguage(locale)))
|
||||
}
|
||||
|
||||
commandOptions := []string{"-y"}
|
||||
commandOptions = append(commandOptions, input...)
|
||||
commandOptions = append(commandOptions, maps...)
|
||||
commandOptions = append(commandOptions, metadata...)
|
||||
// we have to create a temporary file here because it must be seekable
|
||||
// for ffmpeg.
|
||||
// ffmpeg could write to dst too, but this would require to re-encode
|
||||
// the audio which results in much higher time and resource consumption
|
||||
// (0-1 second with the temp file, ~20 seconds with re-encoding on my system)
|
||||
file, err := os.CreateTemp("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.Close()
|
||||
defer os.Remove(file.Name())
|
||||
|
||||
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 utils.Log.IsDev() {
|
||||
var debugOptions []string
|
||||
|
||||
for _, option := range commandOptions {
|
||||
if strings.HasPrefix(option, "title=") {
|
||||
debugOptions = append(debugOptions, "title=\""+strings.TrimPrefix(option, "title=")+"\"")
|
||||
} else if strings.HasPrefix(option, "language=") {
|
||||
debugOptions = append(debugOptions, "language=\""+strings.TrimPrefix(option, "language=")+"\"")
|
||||
} else if strings.Contains(option, " ") {
|
||||
debugOptions = append(debugOptions, "\""+option+"\"")
|
||||
} else {
|
||||
debugOptions = append(debugOptions, option)
|
||||
}
|
||||
}
|
||||
utils.Log.Debug("FFmpeg merge command: ffmpeg %s", strings.Join(debugOptions, " "))
|
||||
}
|
||||
|
||||
var errBuf bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", commandOptions...)
|
||||
cmd.Stderr = &errBuf
|
||||
if err = cmd.Run(); err != nil {
|
||||
return fmt.Errorf(errBuf.String())
|
||||
}
|
||||
|
||||
file, err = os.Open(file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = bufio.NewWriter(dst).ReadFrom(file)
|
||||
return err
|
||||
}
|
||||
|
||||
func archiveExtractEpisodes(url string) ([][]utils.FormatInformation, error) {
|
||||
var hasJapanese bool
|
||||
languagesAsLocale := []crunchyroll.LOCALE{crunchyroll.JP}
|
||||
for _, language := range archiveLanguagesFlag {
|
||||
locale := crunchyroll.LOCALE(language)
|
||||
if locale == crunchyroll.JP {
|
||||
hasJapanese = true
|
||||
} else {
|
||||
languagesAsLocale = append(languagesAsLocale, locale)
|
||||
}
|
||||
}
|
||||
|
||||
episodes, err := utils.ExtractEpisodes(url, languagesAsLocale...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !hasJapanese && len(episodes[1:]) == 0 {
|
||||
return nil, fmt.Errorf("no episodes found")
|
||||
}
|
||||
|
||||
for i, eps := range episodes {
|
||||
if len(eps) == 0 {
|
||||
utils.Log.SetProcess("%s has no matching episodes", languagesAsLocale[i])
|
||||
} else if len(episodes[0]) > len(eps) {
|
||||
utils.Log.SetProcess("%s has %d less episodes than existing in japanese (%d)", languagesAsLocale[i], len(episodes[0])-len(eps), len(episodes[0]))
|
||||
}
|
||||
}
|
||||
|
||||
if !hasJapanese {
|
||||
episodes = episodes[1:]
|
||||
}
|
||||
|
||||
eps := make(map[int]map[int]*utils.FormatInformation)
|
||||
for _, lang := range episodes {
|
||||
for _, season := range crunchyUtils.SortEpisodesBySeason(lang) {
|
||||
if _, ok := eps[season[0].SeasonNumber]; !ok {
|
||||
eps[season[0].SeasonNumber] = map[int]*utils.FormatInformation{}
|
||||
}
|
||||
for _, episode := range season {
|
||||
format, err := episode.GetFormat(archiveResolutionFlag, "", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while receiving format for %s: %v", episode.Title, err)
|
||||
}
|
||||
|
||||
if _, ok := eps[episode.SeasonNumber][episode.EpisodeNumber]; !ok {
|
||||
eps[episode.SeasonNumber][episode.EpisodeNumber] = &utils.FormatInformation{
|
||||
Format: format,
|
||||
AdditionalFormats: make([]*crunchyroll.Format, 0),
|
||||
|
||||
Title: episode.Title,
|
||||
SeriesName: episode.SeriesTitle,
|
||||
SeasonName: episode.SeasonTitle,
|
||||
SeasonNumber: episode.SeasonNumber,
|
||||
EpisodeNumber: episode.EpisodeNumber,
|
||||
Resolution: format.Video.Resolution,
|
||||
FPS: format.Video.FrameRate,
|
||||
Audio: format.AudioLocale,
|
||||
}
|
||||
} else {
|
||||
eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats = append(eps[episode.SeasonNumber][episode.EpisodeNumber].AdditionalFormats, format)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var infoFormat [][]utils.FormatInformation
|
||||
for _, e := range eps {
|
||||
var tmpFormatInfo []utils.FormatInformation
|
||||
|
||||
var keys []int
|
||||
for episodeNumber := range e {
|
||||
keys = append(keys, episodeNumber)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
tmpFormatInfo = append(tmpFormatInfo, *e[key])
|
||||
}
|
||||
|
||||
infoFormat = append(infoFormat, tmpFormatInfo)
|
||||
}
|
||||
|
||||
return infoFormat, nil
|
||||
}
|
||||
136
cli/commands/archive/compress.go
Normal file
136
cli/commands/archive/compress.go
Normal file
|
|
@ -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
|
||||
}
|
||||
341
cli/commands/download/download.go
Normal file
341
cli/commands/download/download.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/ByteDream/crunchy-cli/cli/commands"
|
||||
"github.com/ByteDream/crunchy-cli/utils"
|
||||
"github.com/ByteDream/crunchyroll-go/v3"
|
||||
crunchyUtils "github.com/ByteDream/crunchyroll-go/v3/utils"
|
||||
"github.com/grafov/m3u8"
|
||||
"github.com/spf13/cobra"
|
||||
"math"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
downloadAudioFlag string
|
||||
downloadSubtitleFlag string
|
||||
|
||||
downloadDirectoryFlag string
|
||||
downloadOutputFlag string
|
||||
|
||||
downloadResolutionFlag string
|
||||
|
||||
downloadGoroutinesFlag int
|
||||
)
|
||||
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "download",
|
||||
Short: "Download a video",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
utils.Log.Debug("Validating arguments")
|
||||
|
||||
if filepath.Ext(downloadOutputFlag) != ".ts" {
|
||||
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 {
|
||||
utils.Log.Debug("Custom file ending '%s' (ffmpeg is installed)", filepath.Ext(downloadOutputFlag))
|
||||
}
|
||||
}
|
||||
|
||||
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(), ", "))
|
||||
}
|
||||
utils.Log.Debug("Locales: audio: %s / subtitle: %s", downloadAudioFlag, downloadSubtitleFlag)
|
||||
|
||||
switch downloadResolutionFlag {
|
||||
case "1080p", "720p", "480p", "360p":
|
||||
intRes, _ := strconv.ParseFloat(strings.TrimSuffix(downloadResolutionFlag, "p"), 84)
|
||||
downloadResolutionFlag = fmt.Sprintf("%.0fx%s", math.Ceil(intRes*(float64(16)/float64(9))), strings.TrimSuffix(downloadResolutionFlag, "p"))
|
||||
case "240p":
|
||||
// 240p would round up to 427x240 if used in the case statement above, so it has to be handled separately
|
||||
downloadResolutionFlag = "428x240"
|
||||
case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst":
|
||||
default:
|
||||
return fmt.Errorf("'%s' is not a valid resolution", downloadResolutionFlag)
|
||||
}
|
||||
utils.Log.Debug("Using resolution '%s'", downloadResolutionFlag)
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := commands.LoadCrunchy(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return download(args)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringVarP(&downloadAudioFlag, "audio",
|
||||
"a",
|
||||
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(utils.LocalesAsStrings(), ", "))
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
Cmd.Flags().StringVarP(&downloadDirectoryFlag,
|
||||
"directory",
|
||||
"d",
|
||||
cwd,
|
||||
"The directory to download the file(s) into")
|
||||
Cmd.Flags().StringVarP(&downloadOutputFlag,
|
||||
"output",
|
||||
"o",
|
||||
"{title}.ts",
|
||||
"Name of the output file. "+
|
||||
"If you use the following things in the name, the will get replaced:\n"+
|
||||
"\t{title} » Title of the video\n"+
|
||||
"\t{series_name} » Name of the series\n"+
|
||||
"\t{season_name} » Name of the season\n"+
|
||||
"\t{season_number} » Number of the season\n"+
|
||||
"\t{episode_number} » Number of the episode\n"+
|
||||
"\t{resolution} » Resolution of the video\n"+
|
||||
"\t{fps} » Frame Rate of the video\n"+
|
||||
"\t{audio} » Audio locale of the video\n"+
|
||||
"\t{subtitle} » Subtitle locale of the video")
|
||||
|
||||
Cmd.Flags().StringVarP(&downloadResolutionFlag,
|
||||
"resolution",
|
||||
"r",
|
||||
"best",
|
||||
"The video resolution. Can either be specified via the pixels, the abbreviation for pixels, or 'common-use' words\n"+
|
||||
"\tAvailable pixels: 1920x1080, 1280x720, 640x480, 480x360, 428x240\n"+
|
||||
"\tAvailable abbreviations: 1080p, 720p, 480p, 360p, 240p\n"+
|
||||
"\tAvailable common-use words: best (best available resolution), worst (worst available resolution)")
|
||||
|
||||
Cmd.Flags().IntVarP(&downloadGoroutinesFlag,
|
||||
"goroutines",
|
||||
"g",
|
||||
runtime.NumCPU(),
|
||||
"Sets how many parallel segment downloads should be used")
|
||||
}
|
||||
|
||||
func download(urls []string) error {
|
||||
for i, url := range urls {
|
||||
utils.Log.SetProcess("Parsing url %d", i+1)
|
||||
episodes, err := downloadExtractEpisodes(url)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
utils.Log.StopProcess("Parsed url %d", i+1)
|
||||
|
||||
for _, season := range episodes {
|
||||
utils.Log.Info("%s Season %d", season[0].SeriesName, season[0].SeasonNumber)
|
||||
|
||||
for j, info := range season {
|
||||
utils.Log.Info("\t%d. %s » %spx, %.2f FPS (S%02dE%02d)",
|
||||
j+1,
|
||||
info.Title,
|
||||
info.Resolution,
|
||||
info.FPS,
|
||||
info.SeasonNumber,
|
||||
info.EpisodeNumber)
|
||||
}
|
||||
}
|
||||
utils.Log.Empty()
|
||||
|
||||
for j, season := range episodes {
|
||||
for k, info := range season {
|
||||
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(utils.GenerateFilename(info.FormatString(downloadOutputFlag), dir))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %v", err)
|
||||
}
|
||||
|
||||
if err = downloadInfo(info, file); err != nil {
|
||||
file.Close()
|
||||
os.Remove(file.Name())
|
||||
return err
|
||||
}
|
||||
file.Close()
|
||||
|
||||
if i != len(urls)-1 || j != len(episodes)-1 || k != len(season)-1 {
|
||||
utils.Log.Empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("error while initializing the video: %v", err)
|
||||
}
|
||||
|
||||
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: utils.Log.IsDev(),
|
||||
Quiet: utils.Log.(*commands.Logger).IsQuiet(),
|
||||
}
|
||||
if utils.Log.IsDev() {
|
||||
dp.Prefix = utils.Log.(*commands.Logger).DebugLog.Prefix()
|
||||
}
|
||||
defer func() {
|
||||
if dp.Total != dp.Current {
|
||||
fmt.Println()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
downloader := crunchyroll.NewDownloader(ctx, file, downloadGoroutinesFlag, func(segment *m3u8.MediaSegment, current, total int, file *os.File) error {
|
||||
// check if the context was cancelled.
|
||||
// must be done in to not print any progress messages if ctrl+c was pressed
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if current == total {
|
||||
dp.UpdateMessage("Merging segments", false)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if utils.HasFFmpeg() {
|
||||
downloader.FFmpegOpts = make([]string, 0)
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
go func() {
|
||||
select {
|
||||
case <-sig:
|
||||
signal.Stop(sig)
|
||||
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
|
||||
// directory where the segments are downloaded to will not be deleted
|
||||
case <-ctx.Done():
|
||||
// this is just here to end the goroutine and prevent it from running forever without a reason
|
||||
}
|
||||
}()
|
||||
utils.Log.Debug("Set up signal catcher")
|
||||
|
||||
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)
|
||||
utils.Log.Debug("Stopped signal catcher")
|
||||
|
||||
utils.Log.Empty()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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(crunchyUtils.EpisodesByNumber(japanese))
|
||||
sort.Sort(crunchyUtils.EpisodesByNumber(custom))
|
||||
|
||||
var errMessages []string
|
||||
|
||||
var final []*crunchyroll.Episode
|
||||
if len(japanese) == 0 || len(japanese) == len(custom) {
|
||||
final = custom
|
||||
} else {
|
||||
for _, jp := range japanese {
|
||||
before := len(final)
|
||||
for _, episode := range custom {
|
||||
if jp.SeasonNumber == episode.SeasonNumber && jp.EpisodeNumber == episode.EpisodeNumber {
|
||||
final = append(final, episode)
|
||||
}
|
||||
}
|
||||
if before == len(final) {
|
||||
errMessages = append(errMessages, fmt.Sprintf("%s has no %s audio, using %s as fallback", jp.Title, crunchyroll.LOCALE(downloadAudioFlag), crunchyroll.JP))
|
||||
final = append(final, jp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errMessages) > 10 {
|
||||
for _, msg := range errMessages[:10] {
|
||||
utils.Log.SetProcess(msg)
|
||||
}
|
||||
utils.Log.SetProcess("... and %d more", len(errMessages)-10)
|
||||
} else {
|
||||
for _, msg := range errMessages {
|
||||
utils.Log.SetProcess(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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, utils.FormatInformation{
|
||||
Format: format,
|
||||
|
||||
Title: episode.Title,
|
||||
SeriesName: episode.SeriesTitle,
|
||||
SeasonName: episode.SeasonTitle,
|
||||
SeasonNumber: episode.SeasonNumber,
|
||||
EpisodeNumber: episode.EpisodeNumber,
|
||||
Resolution: format.Video.Resolution,
|
||||
FPS: format.Video.FrameRate,
|
||||
Audio: format.AudioLocale,
|
||||
})
|
||||
}
|
||||
infoFormat = append(infoFormat, tmpFormatInformation)
|
||||
}
|
||||
return infoFormat, nil
|
||||
}
|
||||
40
cli/commands/info/info.go
Normal file
40
cli/commands/info/info.go
Normal file
|
|
@ -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
|
||||
}
|
||||
196
cli/commands/logger.go
Normal file
196
cli/commands/logger.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ByteDream/crunchy-cli/utils"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var prefix, progressDown, progressDownFinish string
|
||||
|
||||
func initPrefixBecauseWindowsSucksBallsHard() {
|
||||
// dear windows user, please change to a good OS, linux in the best case.
|
||||
// MICROSHIT DOES NOT GET IT DONE TO SHOW THE SYMBOLS IN THE ELSE CLAUSE
|
||||
// CORRECTLY. NOT IN THE CMD NOR POWERSHELL. WHY TF, IT IS ONE OF THE MOST
|
||||
// PROFITABLE COMPANIES ON THIS PLANET AND CANNOT SHOW A PROPER UTF-8 SYMBOL
|
||||
// IN THEIR OWN PRODUCT WHICH GETS USED MILLION TIMES A DAY
|
||||
if runtime.GOOS == "windows" {
|
||||
prefix = "=>"
|
||||
progressDown = "|"
|
||||
progressDownFinish = "->"
|
||||
} else {
|
||||
prefix = "➞"
|
||||
progressDown = "↓"
|
||||
progressDownFinish = "↳"
|
||||
}
|
||||
}
|
||||
|
||||
type progress struct {
|
||||
message string
|
||||
stop bool
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if debug {
|
||||
debugLog.SetOutput(os.Stdout)
|
||||
}
|
||||
if info {
|
||||
infoLog.SetOutput(os.Stdout)
|
||||
}
|
||||
if err {
|
||||
errLog.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
if debug {
|
||||
debugLog = log.New(debugLog.Writer(), "[debug] ", 0)
|
||||
infoLog = log.New(infoLog.Writer(), "[info] ", 0)
|
||||
errLog = log.New(errLog.Writer(), "[err] ", 0)
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
DebugLog: debugLog,
|
||||
InfoLog: infoLog,
|
||||
ErrLog: errLog,
|
||||
|
||||
devView: debug,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return l.DebugLog.Writer() == io.Discard && l.InfoLog.Writer() == io.Discard && l.ErrLog.Writer() == io.Discard
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(format string, v ...interface{}) {
|
||||
l.DebugLog.Printf(format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Info(format string, v ...interface{}) {
|
||||
l.InfoLog.Printf(format, v...)
|
||||
}
|
||||
|
||||
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) Empty() {
|
||||
if !l.devView && l.InfoLog.Writer() != io.Discard {
|
||||
fmt.Println("")
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) SetProcess(format string, v ...interface{}) {
|
||||
if l.InfoLog.Writer() == io.Discard {
|
||||
return
|
||||
} else if l.devView {
|
||||
l.Debug(format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
initialMessage := fmt.Sprintf(format, v...)
|
||||
|
||||
p := progress{
|
||||
message: initialMessage,
|
||||
}
|
||||
|
||||
l.lock.Lock()
|
||||
if l.done != nil {
|
||||
l.progress <- p
|
||||
return
|
||||
} else {
|
||||
l.progress = make(chan progress, 1)
|
||||
l.progress <- p
|
||||
l.done = make(chan interface{})
|
||||
}
|
||||
|
||||
go func() {
|
||||
states := []string{"-", "\\", "|", "/"}
|
||||
|
||||
var count int
|
||||
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case p := <-l.progress:
|
||||
if p.stop {
|
||||
fmt.Printf("\r" + strings.Repeat(" ", len(prefix)+len(initialMessage)))
|
||||
if count > 1 {
|
||||
fmt.Printf("\r%s %s\n", progressDownFinish, p.message)
|
||||
} else {
|
||||
fmt.Printf("\r%s %s\n", prefix, p.message)
|
||||
}
|
||||
|
||||
if l.done != nil {
|
||||
l.done <- nil
|
||||
}
|
||||
l.progress = nil
|
||||
|
||||
l.lock.Unlock()
|
||||
return
|
||||
} else {
|
||||
if count > 0 {
|
||||
fmt.Printf("\r%s %s\n", progressDown, p.message)
|
||||
}
|
||||
l.progress = make(chan progress, 1)
|
||||
|
||||
count++
|
||||
|
||||
fmt.Printf("\r%s %s", states[i/10%4], initialMessage)
|
||||
l.lock.Unlock()
|
||||
}
|
||||
default:
|
||||
if i%10 == 0 {
|
||||
fmt.Printf("\r%s %s", states[i/10%4], initialMessage)
|
||||
}
|
||||
time.Sleep(35 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (l *Logger) StopProcess(format string, v ...interface{}) {
|
||||
if l.InfoLog.Writer() == io.Discard {
|
||||
return
|
||||
} else if l.devView {
|
||||
l.Debug(format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
l.lock.Lock()
|
||||
l.progress <- progress{
|
||||
message: fmt.Sprintf(format, v...),
|
||||
stop: true,
|
||||
}
|
||||
<-l.done
|
||||
l.done = nil
|
||||
}
|
||||
158
cli/commands/login/login.go
Normal file
158
cli/commands/login/login.go
Normal file
|
|
@ -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
|
||||
}
|
||||
48
cli/commands/unix.go
Normal file
48
cli/commands/unix.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// https://github.com/bgentry/speakeasy/blob/master/speakeasy_unix.go
|
||||
var stty string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
if stty, err = exec.LookPath("stty"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func ReadLineSilent() ([]byte, error) {
|
||||
pid, err := setEcho(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer setEcho(true)
|
||||
|
||||
syscall.Wait4(pid, nil, 0, nil)
|
||||
|
||||
l, _, err := bufio.NewReader(os.Stdin).ReadLine()
|
||||
return l, err
|
||||
}
|
||||
|
||||
func setEcho(on bool) (pid int, err error) {
|
||||
fds := []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()}
|
||||
|
||||
if on {
|
||||
pid, err = syscall.ForkExec(stty, []string{"stty", "echo"}, &syscall.ProcAttr{Files: fds})
|
||||
} else {
|
||||
pid, err = syscall.ForkExec(stty, []string{"stty", "-echo"}, &syscall.ProcAttr{Files: fds})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return
|
||||
}
|
||||
135
cli/commands/update/update.go
Normal file
135
cli/commands/update/update.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/ByteDream/crunchy-cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
updateInstallFlag bool
|
||||
)
|
||||
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check if updates are available",
|
||||
Args: cobra.MaximumNArgs(0),
|
||||
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return update()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().BoolVarP(&updateInstallFlag,
|
||||
"install",
|
||||
"i",
|
||||
false,
|
||||
"If set and a new version is available, the new version gets installed")
|
||||
}
|
||||
|
||||
func update() error {
|
||||
var release map[string]interface{}
|
||||
|
||||
resp, err := utils.Client.Get("https://api.github.com/repos/ByteDream/crunchy-cli/releases/latest")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseVersion := strings.TrimPrefix(release["tag_name"].(string), "v")
|
||||
|
||||
if utils.Version == "development" {
|
||||
utils.Log.Info("Development version, update service not available")
|
||||
return nil
|
||||
}
|
||||
|
||||
latestRelease := strings.SplitN(releaseVersion, ".", 4)
|
||||
if len(latestRelease) != 3 {
|
||||
return fmt.Errorf("latest tag name (%s) is not parsable", releaseVersion)
|
||||
}
|
||||
|
||||
internalVersion := strings.SplitN(utils.Version, ".", 4)
|
||||
if len(internalVersion) != 3 {
|
||||
return fmt.Errorf("internal version (%s) is not parsable", utils.Version)
|
||||
}
|
||||
|
||||
utils.Log.Info("Installed version is %s", utils.Version)
|
||||
|
||||
var hasUpdate bool
|
||||
for i := 0; i < 3; i++ {
|
||||
if latestRelease[i] < internalVersion[i] {
|
||||
utils.Log.Info("Local version is newer than version in latest release (%s)", releaseVersion)
|
||||
return nil
|
||||
} else if latestRelease[i] > internalVersion[i] {
|
||||
hasUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpdate {
|
||||
utils.Log.Info("Version is up-to-date")
|
||||
return nil
|
||||
}
|
||||
|
||||
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" {
|
||||
return fmt.Errorf("invalid architecture found (%s), only amd64 is currently supported for automatic updating. "+
|
||||
"You have to update manually (https://github.com/ByteDream/crunchy-cli)", runtime.GOARCH)
|
||||
}
|
||||
|
||||
var downloadFile string
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
yayCommand := exec.Command("pacman -Q crunchy-cli")
|
||||
if yayCommand.Run() == nil && yayCommand.ProcessState.Success() {
|
||||
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)
|
||||
case "darwin":
|
||||
downloadFile = fmt.Sprintf("crunchy-v%s_darwin", releaseVersion)
|
||||
case "windows":
|
||||
downloadFile = fmt.Sprintf("crunchy-v%s_windows.exe", releaseVersion)
|
||||
default:
|
||||
return fmt.Errorf("invalid operation system found (%s), only linux, windows and darwin / macos are currently supported. "+
|
||||
"You have to update manually (https://github.com/ByteDream/crunchy-cli", runtime.GOOS)
|
||||
}
|
||||
|
||||
utils.Log.SetProcess("Updating executable %s", os.Args[0])
|
||||
|
||||
perms, err := os.Stat(os.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(os.Args[0])
|
||||
executeFile, err := os.OpenFile(os.Args[0], os.O_CREATE|os.O_WRONLY, perms.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer executeFile.Close()
|
||||
|
||||
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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if _, err = io.Copy(executeFile, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
utils.Log.StopProcess("Updated executable %s", os.Args[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
125
cli/commands/utils.go
Normal file
125
cli/commands/utils.go
Normal file
|
|
@ -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
|
||||
}
|
||||
41
cli/commands/windows.go
Normal file
41
cli/commands/windows.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//go:build windows
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// https://github.com/bgentry/speakeasy/blob/master/speakeasy_windows.go
|
||||
func ReadLineSilent() ([]byte, error) {
|
||||
var oldMode uint32
|
||||
|
||||
if err := syscall.GetConsoleMode(syscall.Stdin, &oldMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newMode := oldMode &^ 0x0004
|
||||
|
||||
err := setConsoleMode(syscall.Stdin, newMode)
|
||||
defer setConsoleMode(syscall.Stdin, oldMode)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l, _, err := bufio.NewReader(os.Stdin).ReadLine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
func setConsoleMode(console syscall.Handle, mode uint32) error {
|
||||
dll := syscall.MustLoadDLL("kernel32")
|
||||
proc := dll.MustFindProc("SetConsoleMode")
|
||||
_, _, err := proc.Call(uintptr(console), uintptr(mode))
|
||||
|
||||
return err
|
||||
}
|
||||
85
cli/root.go
Normal file
85
cli/root.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue