package commands import ( "bytes" "crypto/md5" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "quyun/app/service" "go.ipao.vip/atom" "go.ipao.vip/atom/container" "github.com/pkg/errors" "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "go.uber.org/dig" ) func defaultProviders() container.Providers { return service.Default(container.Providers{}...) } func Command() atom.Option { return atom.Command( atom.Name("compress"), atom.Short("compress videos"), atom.RunE(Serve), atom.Providers( defaultProviders().With(), ), ) } type Service struct { dig.In } var compressed []compressedDB type compressedDB struct { Name string `json:"name"` Md5 string `json:"md5"` } func Serve(cmd *cobra.Command, args []string) error { // log.SetFormatter(&log.JSONFormatter{}) if len(args) == 0 { return errors.New("usage: compress path") } log.Infof("start compressing %s", args[0]) failTimes := 0 for { log.Infof("start compressing %s", args[0]) if err := commandCompress(args[0]); err != nil { log.WithError(err).Errorf("compressing %s failed", args[0]) failTimes++ if failTimes >= 3 { return errors.New("failed to compress after 3 attempts") } continue } log.Infof("compressing %s done", args[0]) time.Sleep(5 * time.Minute) } } func commandCompress(dir string) error { dstPath := filepath.Join(dir, "compressed") // check if the directory exists if _, err := os.Stat(dstPath); os.IsNotExist(err) { if err := os.MkdirAll(dstPath, 0o755); err != nil { return errors.Wrapf(err, "failed to create directory: %s", dstPath) } } if err := readDB(dir); err != nil { return errors.Wrap(err, "failed to read db") } dirFiles, err := os.ReadDir(dir) if err != nil { return errors.Wrapf(err, "failed to read directory: %s", dir) } // loop through the files for _, file := range dirFiles { // check if the file is a video if file.IsDir() { continue } log.Infof("process file %s", file.Name()) // get the file name fileName := file.Name() filePath := filepath.Join(dir, fileName) dstPath := filepath.Join(dstPath, fileName) fileMd5, err := calculateMD5(filePath) if err != nil { return err } if _, ok := lo.Find(compressed, func(c compressedDB) bool { return c.Md5 == fileMd5 }); ok { log.Infof("file %s already compressed", fileName) continue } // get the file path if err := doCompress(filePath, dstPath); err != nil { log.WithError(err).Errorf("failed to compress file: %s", fileName) continue } compressed = append(compressed, compressedDB{Name: fileName, Md5: fileMd5}) writeDB(dir) log.Infof("file %s compressed", fileName) } return nil } func readDB(path string) error { dbFile := filepath.Join(path, ".compressed.json") // check if the file exists if _, err := os.Stat(dbFile); os.IsNotExist(err) { // create the file file, err := os.Create(dbFile) if err != nil { return errors.Wrapf(err, "failed to create file: %s", dbFile) } defer file.Close() // write the file _, err = file.WriteString("[]") if err != nil { return errors.Wrapf(err, "failed to write file: %s", dbFile) } } // read the db file, err := os.ReadFile(dbFile) if err != nil { return errors.Wrapf(err, "failed to read file: %s", dbFile) } err = json.Unmarshal(file, &compressed) if err != nil { return errors.Wrapf(err, "failed to unmarshal file: %s", dbFile) } return nil } func writeDB(path string) error { dbFile := filepath.Join(path, ".compressed.json") // write the db data, err := json.Marshal(compressed) if err != nil { log.Errorf("failed to marshal file: %s", dbFile) } err = os.WriteFile(dbFile, data, 0o644) if err != nil { log.Errorf("failed to write file: %s", dbFile) return err } return nil } // calculateMD5 calculates the md5 of a file func calculateMD5(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", errors.Wrapf(err, "failed to open file: %s", filePath) } defer file.Close() hash := md5.New() if _, err := io.Copy(hash, file); err != nil { return "", errors.Wrapf(err, "failed to copy file: %s", filePath) } return fmt.Sprintf("%x", hash.Sum(nil)), nil } // doCompress compresses the video file func doCompress(srcPath, dstPath string) error { // get file ext ext := strings.ToLower(filepath.Ext(srcPath)) if ext == "" { return errors.New("file has no extension") } // check if the file is a .mp3 if strings.EqualFold(ext, ".mp3") { return compressAudio(srcPath, dstPath) } // check if the file is a video videoExts := []string{".mp4", ".avi", ".mkv"} if !lo.Contains(videoExts, ext) { return errors.New("file is not a video") } return compressVideo(srcPath, dstPath) } // copyFile copies the file from src to dst func copyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return errors.Wrapf(err, "failed to open file: %s", src) } defer srcFile.Close() dstFile, err := os.Create(dst) if err != nil { return errors.Wrapf(err, "failed to create file: %s", dst) } defer dstFile.Close() if _, err := io.Copy(dstFile, srcFile); err != nil { return errors.Wrapf(err, "failed to copy file: %s", src) } return nil } // compress audio func compressAudio(srcPath, dstPath string) error { log.Infof("compress audio from %s to %s", srcPath, dstPath) if err := copyFile(srcPath, dstPath); err != nil { return errors.Wrapf(err, "failed to copy file: %s", srcPath) } return nil } // compress video func compressVideo(srcPath, dstPath string) error { // if file size < 50M then copy fileInfo, err := os.Stat(srcPath) if err != nil { return errors.Wrapf(err, "failed to get file info: %s", srcPath) } if fileInfo.Size() < 50*1024*1024 { // 50 MB log.Infof("file size < 50M, copy file from %s to %s", srcPath, dstPath) return copyFile(srcPath, dstPath) } width, height, err := getVideoSize(srcPath) if err != nil { return errors.Wrapf(err, "failed to get video size: %s", srcPath) } log.Infof("video size: %dx%d", width, height) // cmd := exec.Command("ffmpeg", "-i", srcPath, "-c:v", "libx264", "-crf", "23", "-r", "25", "-c:a", "copy", fmt.Sprintf("'%s'", dstPath)) cmd := exec.Command("sh", "-c", fmt.Sprintf("ffmpeg -i '%s' -y -c:v libx264 -crf 23 -r 25 -c:a copy '%s'", srcPath, dstPath)) if width > 1920 || height > 1080 { cmd = exec.Command("sh", "-c", fmt.Sprintf("ffmpeg -i '%s' -y -c:v libx264 -crf 23 -r 25 -c:a copy -vf scale=1920:1080 '%s'", srcPath, dstPath)) } var stderr bytes.Buffer cmd.Stderr = &stderr log.Infof("executing command: %s", cmd.String()) if err := cmd.Run(); err != nil { log.Errorf("stderr: %s", stderr.String()) return errors.Wrapf(err, "failed to execute compress command: %s", cmd.String()) } return nil } func getVideoSize(filePath string) (int, int, error) { var stderr bytes.Buffer cmd := exec.Command("sh", "-c", fmt.Sprintf("ffprobe -v error -select_streams v:0 -show_entries stream=height,width -of default=noprint_wrappers=1:nokey=1 '%s'", filePath)) cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { log.Errorf("stderr: %s", stderr.String()) return 0, 0, errors.Wrapf(err, "failed to execute command: %s", cmd.String()) } lines := strings.Split(string(out), "\n") if len(lines) < 2 { return 0, 0, errors.New("failed to get video size") } // parse the height and width width, err := strconv.Atoi(strings.TrimSpace(lines[0])) if err != nil { return 0, 0, errors.Wrap(err, "failed to parse height") } height, err := strconv.Atoi(strings.TrimSpace(lines[1])) if err != nil { return 0, 0, errors.Wrap(err, "failed to parse width") } return width, height, nil }