Remove library & refactor cli

This commit is contained in:
bytedream 2022-06-27 22:33:26 +02:00
parent 0fed0f8d3b
commit 8a3e42e4d1
45 changed files with 117 additions and 3687 deletions

819
commands/archive.go Normal file
View file

@ -0,0 +1,819 @@
package commands
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"context"
"fmt"
"github.com/ByteDream/crunchyroll-go/v3"
"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"
"sync"
"time"
)
var (
archiveLanguagesFlag []string
archiveDirectoryFlag string
archiveOutputFlag string
archiveMergeFlag string
archiveCompressFlag string
archiveResolutionFlag string
archiveGoroutinesFlag int
)
var archiveCmd = &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")
if !hasFFmpeg() {
return fmt.Errorf("ffmpeg is needed to run this command correctly")
}
out.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 locale is 'all', match all known locales
if locale == "all" {
archiveLanguagesFlag = allLocalesAsStrings()
break
}
return fmt.Errorf("%s is not a valid locale. Choose from: %s", locale, strings.Join(allLocalesAsStrings(), ", "))
}
}
out.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)
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) {
out.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(downloadResolutionFlag, "p"), 84)
archiveResolutionFlag = 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
archiveResolutionFlag = "428x240"
case "1920x1080", "1280x720", "640x480", "480x360", "428x240", "best", "worst":
default:
return fmt.Errorf("'%s' is not a valid resolution", archiveResolutionFlag)
}
out.Debug("Using resolution '%s'", archiveResolutionFlag)
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
loadCrunchy()
return archive(args)
},
}
func init() {
archiveCmd.Flags().StringSliceVarP(&archiveLanguagesFlag,
"language",
"l",
[]string{string(systemLocale(false)), string(crunchyroll.JP)},
"Audio locale which should be downloaded. Can be used multiple times")
cwd, _ := os.Getwd()
archiveCmd.Flags().StringVarP(&archiveDirectoryFlag,
"directory",
"d",
cwd,
"The directory to store the files into")
archiveCmd.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")
archiveCmd.Flags().StringVarP(&archiveMergeFlag,
"merge",
"m",
"auto",
"Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio', 'video'")
archiveCmd.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")
archiveCmd.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)")
archiveCmd.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)
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, " +
"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)
var compressFile *os.File
var c compress
if archiveCompressFlag != "" {
compressFile, err = os.Create(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 {
out.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)",
j+1,
info.Title,
info.Resolution,
info.FPS,
info.SeasonNumber,
info.EpisodeNumber)
}
}
out.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)
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)
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)
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 {
out.Empty()
}
}
}
if c != nil {
c.Close()
}
if compressFile != nil {
compressFile.Close()
}
}
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))
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 out.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)
out.Exit("Exiting... (may take a few seconds)")
out.Exit("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
}
}()
out.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"
}
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)
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(utils.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)
out.Debug("Stopped signal catcher")
out.Empty()
return nil
}
func createArchiveProgress(info formatInformation) (*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 := &downloadProgress{
Prefix: out.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(),
}
if out.IsDev() {
dp.Prefix = out.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()
out.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()
out.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", utils.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)))
}
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", utils.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", utils.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 out.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)
}
}
out.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) ([][]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 := 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 {
out.SetProgress("%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]))
}
}
if !hasJapanese {
episodes = episodes[1:]
}
eps := make(map[int]map[int]*formatInformation)
for _, lang := range episodes {
for _, season := range utils.SortEpisodesBySeason(lang) {
if _, ok := eps[season[0].SeasonNumber]; !ok {
eps[season[0].SeasonNumber] = map[int]*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] = &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 [][]formatInformation
for _, e := range eps {
var tmpFormatInfo []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
}
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
}

339
commands/download.go Normal file
View file

@ -0,0 +1,339 @@
package commands
import (
"context"
"fmt"
"github.com/ByteDream/crunchyroll-go/v3"
"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 downloadCmd = &cobra.Command{
Use: "download",
Short: "Download a video",
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
out.Debug("Validating arguments")
if filepath.Ext(downloadOutputFlag) != ".ts" {
if !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))
}
}
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(), ", "))
}
out.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)
}
out.Debug("Using resolution '%s'", downloadResolutionFlag)
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
loadCrunchy()
return download(args)
},
}
func init() {
downloadCmd.Flags().StringVarP(&downloadAudioFlag, "audio",
"a",
string(systemLocale(false)),
"The locale of the audio. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
downloadCmd.Flags().StringVarP(&downloadSubtitleFlag,
"subtitle",
"s",
"",
"The locale of the subtitle. Available locales: "+strings.Join(allLocalesAsStrings(), ", "))
cwd, _ := os.Getwd()
downloadCmd.Flags().StringVarP(&downloadDirectoryFlag,
"directory",
"d",
cwd,
"The directory to download the file(s) into")
downloadCmd.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")
downloadCmd.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)")
downloadCmd.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)
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, " +
"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)
for _, season := range episodes {
out.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)",
j+1,
info.Title,
info.Resolution,
info.FPS,
info.SeasonNumber,
info.EpisodeNumber)
}
}
out.Empty()
for j, season := range episodes {
for k, info := range season {
dir := info.Format(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))
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 {
out.Empty()
}
}
}
}
return nil
}
func downloadInfo(info formatInformation, file *os.File) error {
out.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 := &downloadProgress{
Prefix: out.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(),
}
if out.IsDev() {
dp.Prefix = out.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 out.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 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)
out.Exit("Exiting... (may take a few seconds)")
out.Exit("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
}
}()
out.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 {
return fmt.Errorf("error while downloading: %v", err)
}
dp.UpdateMessage("Download finished", false)
signal.Stop(sig)
out.Debug("Stopped signal catcher")
out.Empty()
return nil
}
func downloadExtractEpisodes(url string) ([][]formatInformation, error) {
episodes, err := 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))
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] {
out.SetProgress(msg)
}
out.SetProgress("... and %d more", len(errMessages)-10)
} else {
for _, msg := range errMessages {
out.SetProgress(msg)
}
}
var infoFormat [][]formatInformation
for _, season := range utils.SortEpisodesBySeason(final) {
tmpFormatInformation := make([]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,
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
commands/info.go Normal file
View file

@ -0,0 +1,40 @@
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
}

193
commands/logger.go Normal file
View file

@ -0,0 +1,193 @@
package commands
import (
"fmt"
"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
}
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 {
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,
}
}
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) 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() {
if !l.devView && l.InfoLog.Writer() != io.Discard {
fmt.Println("")
}
}
func (l *logger) SetProgress(format string, v ...interface{}) {
if out.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) StopProgress(format string, v ...interface{}) {
if out.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
}

206
commands/login.go Normal file
View file

@ -0,0 +1,206 @@
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
}

78
commands/root.go Normal file
View file

@ -0,0 +1,78 @@
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)
}
}

48
commands/unix.go Normal file
View 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
}

136
commands/update.go Normal file
View file

@ -0,0 +1,136 @@
package commands
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"io"
"os"
"os/exec"
"runtime"
"strings"
)
var (
updateInstallFlag bool
)
var updateCmd = &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() {
updateCmd.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")
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 Version == "development" {
out.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(Version, ".", 4)
if len(internalVersion) != 3 {
return fmt.Errorf("internal version (%s) is not parsable", Version)
}
out.Info("Installed version is %s", 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)
return nil
} else if latestRelease[i] > internalVersion[i] {
hasUpdate = true
}
}
if !hasUpdate {
out.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)
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() {
out.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)
}
out.SetProgress("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 := 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
}
out.StopProgress("Updated executable %s", os.Args[0])
}
return nil
}

485
commands/utils.go Normal file
View file

@ -0,0 +1,485 @@
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)
}

41
commands/windows.go Normal file
View 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
}