1
0
forked from um/cli
cli/cmd/um/main.go

414 lines
11 KiB
Go
Raw Permalink Normal View History

2020-12-26 15:22:39 +00:00
package main
import (
"bytes"
2022-11-21 22:45:18 +00:00
"context"
2020-12-26 15:22:39 +00:00
"errors"
2021-11-11 16:23:37 +00:00
"fmt"
"io"
2021-12-13 20:01:04 +00:00
"os"
2022-12-25 12:23:59 +00:00
"os/signal"
"path"
2021-12-13 20:01:04 +00:00
"path/filepath"
"runtime"
2022-11-18 23:25:39 +00:00
"runtime/debug"
2021-12-13 20:01:04 +00:00
"sort"
"strings"
2022-11-18 23:25:39 +00:00
"time"
2021-12-13 20:01:04 +00:00
"github.com/fsnotify/fsnotify"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
2022-11-18 23:44:44 +00:00
"unlock-music.dev/cli/algo/common"
_ "unlock-music.dev/cli/algo/kgm"
_ "unlock-music.dev/cli/algo/kwm"
_ "unlock-music.dev/cli/algo/ncm"
2024-02-12 16:58:13 +00:00
"unlock-music.dev/cli/algo/qmc"
2022-11-18 23:44:44 +00:00
_ "unlock-music.dev/cli/algo/tm"
2022-11-19 18:18:50 +00:00
_ "unlock-music.dev/cli/algo/xiami"
2022-11-19 23:53:36 +00:00
_ "unlock-music.dev/cli/algo/ximalaya"
"unlock-music.dev/cli/internal/ffmpeg"
2022-11-18 23:44:44 +00:00
"unlock-music.dev/cli/internal/logging"
"unlock-music.dev/cli/internal/sniff"
"unlock-music.dev/cli/internal/utils"
2020-12-26 15:22:39 +00:00
)
2024-10-20 23:00:51 +00:00
var AppVersion = "v0.2.7"
2021-05-16 04:30:48 +00:00
2022-11-18 23:25:41 +00:00
var logger, _ = logging.NewZapLogger() // TODO: inject logger to application, instead of using global logger
2020-12-26 15:22:39 +00:00
func main() {
2022-11-18 23:25:39 +00:00
module, ok := debug.ReadBuildInfo()
if ok && module.Main.Version != "(devel)" {
AppVersion = module.Main.Version
}
2020-12-26 15:22:39 +00:00
app := cli.App{
Name: "Unlock Music CLI",
HelpName: "um",
2022-11-18 23:25:39 +00:00
Usage: "Unlock your encrypted music file https://git.unlock-music.dev/um/cli",
2021-11-11 16:23:37 +00:00
Version: fmt.Sprintf("%s (%s,%s/%s)", AppVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH),
2020-12-26 15:22:39 +00:00
Flags: []cli.Flag{
2021-05-16 08:59:49 +00:00
&cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or dir", Required: false},
&cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false},
2024-09-04 22:03:35 +00:00
&cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to qmc mmkv (.crc file also required)", Required: false},
&cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "mmkv password (16 ascii chars)", Required: false},
2022-02-24 15:32:17 +00:00
&cli.BoolFlag{Name: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: false},
2021-11-11 15:43:20 +00:00
&cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
2022-12-06 16:40:12 +00:00
&cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false},
2022-12-25 11:26:11 +00:00
&cli.BoolFlag{Name: "overwrite", Usage: "overwrite output file without asking", Required: false, Value: false},
2022-12-25 12:23:59 +00:00
&cli.BoolFlag{Name: "watch", Usage: "watch the input dir and process new files", Required: false, Value: false},
2022-12-06 16:40:12 +00:00
&cli.BoolFlag{Name: "supported-ext", Usage: "show supported file extensions and exit", Required: false, Value: false},
2020-12-26 15:22:39 +00:00
},
2021-05-16 08:59:49 +00:00
2020-12-26 15:22:39 +00:00
Action: appMain,
2022-11-18 23:25:39 +00:00
Copyright: fmt.Sprintf("Copyright (c) 2020 - %d Unlock Music https://git.unlock-music.dev/um/cli/src/branch/master/LICENSE", time.Now().Year()),
2020-12-26 15:22:39 +00:00
HideHelpCommand: true,
2021-11-11 15:43:20 +00:00
UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input",
2020-12-26 15:22:39 +00:00
}
err := app.Run(os.Args)
if err != nil {
2022-11-18 23:25:41 +00:00
logger.Fatal("run app failed", zap.Error(err))
2020-12-26 15:22:39 +00:00
}
}
2022-12-25 12:23:59 +00:00
2021-12-13 20:01:04 +00:00
func printSupportedExtensions() {
2022-11-18 23:25:41 +00:00
var exts []string
extSet := make(map[string]int)
for _, factory := range common.DecoderRegistry {
ext := strings.TrimPrefix(factory.Suffix, ".")
if n, ok := extSet[ext]; ok {
extSet[ext] = n + 1
} else {
extSet[ext] = 1
}
}
for ext := range extSet {
2021-12-13 20:01:04 +00:00
exts = append(exts, ext)
}
sort.Strings(exts)
for _, ext := range exts {
fmt.Printf("%s: %d\n", ext, extSet[ext])
2021-12-13 20:01:04 +00:00
}
}
2022-12-25 12:23:59 +00:00
2021-11-11 16:23:37 +00:00
func appMain(c *cli.Context) (err error) {
cwd, err := os.Getwd()
if err != nil {
return err
}
2021-12-13 20:01:04 +00:00
if c.Bool("supported-ext") {
printSupportedExtensions()
return nil
}
2020-12-26 15:22:39 +00:00
input := c.String("input")
2021-11-11 16:23:37 +00:00
if input == "" {
switch c.Args().Len() {
case 0:
input = cwd
2021-11-11 16:23:37 +00:00
case 1:
input = c.Args().Get(0)
default:
return errors.New("please specify input file (or directory)")
}
2021-05-16 08:59:49 +00:00
}
2020-12-26 15:22:39 +00:00
output := c.String("output")
inputStat, err := os.Stat(input)
if err != nil {
return err
}
if output == "" {
// Default to where the input is
if inputStat.IsDir() {
output = input
} else {
output = path.Dir(input)
}
}
2020-12-26 15:22:39 +00:00
outputStat, err := os.Stat(output)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
err = os.MkdirAll(output, 0755)
}
if err != nil {
return err
}
} else if !outputStat.IsDir() {
return errors.New("output should be a writable directory")
}
if mmkv := c.String("qmc-mmkv"); mmkv != "" {
// If key is not set, the mmkv vault will be treated as unencrypted.
key := c.String("qmc-mmkv-key")
err := qmc.OpenMMKV(mmkv, key, logger)
2024-02-14 08:59:05 +00:00
if err != nil {
return err
}
}
2022-12-06 16:40:12 +00:00
proc := &processor{
inputDir: input,
2022-12-06 16:40:12 +00:00
outputDir: output,
skipNoopDecoder: c.Bool("skip-noop"),
removeSource: c.Bool("remove-source"),
updateMetadata: c.Bool("update-metadata"),
2022-12-25 11:26:11 +00:00
overwriteOutput: c.Bool("overwrite"),
2022-12-06 16:40:12 +00:00
}
2020-12-26 15:22:39 +00:00
if inputStat.IsDir() {
2024-10-08 20:59:27 +00:00
watchDir := c.Bool("watch")
if !watchDir {
2022-12-25 12:23:59 +00:00
return proc.processDir(input)
} else {
return proc.watchDir(input)
}
2020-12-26 15:22:39 +00:00
} else {
2022-12-06 16:40:12 +00:00
return proc.processFile(input)
2020-12-26 15:22:39 +00:00
}
}
2022-12-06 16:40:12 +00:00
type processor struct {
inputDir string
2022-12-06 16:40:12 +00:00
outputDir string
skipNoopDecoder bool
removeSource bool
updateMetadata bool
2022-12-25 11:26:11 +00:00
overwriteOutput bool
2022-12-06 16:40:12 +00:00
}
2022-12-25 12:23:59 +00:00
func (p *processor) watchDir(inputDir string) error {
if err := p.processDir(inputDir); err != nil {
return err
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create watcher: %w", err)
}
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
// try open with exclusive mode, to avoid file is still writing
f, err := os.OpenFile(event.Name, os.O_RDONLY, os.ModeExclusive)
if err != nil {
logger.Debug("failed to open file exclusively", zap.String("path", event.Name), zap.Error(err))
time.Sleep(1 * time.Second) // wait for file writing complete
continue
}
_ = f.Close()
if err := p.processFile(event.Name); err != nil {
logger.Warn("failed to process file", zap.String("path", event.Name), zap.Error(err))
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Error("file watcher got error", zap.Error(err))
}
}
}()
err = watcher.Add(inputDir)
if err != nil {
return fmt.Errorf("failed to watch dir %s: %w", inputDir, err)
}
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
<-signalCtx.Done()
return nil
}
2022-12-06 16:40:12 +00:00
func (p *processor) processDir(inputDir string) error {
2020-12-26 15:22:39 +00:00
items, err := os.ReadDir(inputDir)
if err != nil {
return err
}
var lastError error = nil
2020-12-26 15:22:39 +00:00
for _, item := range items {
filePath := filepath.Join(inputDir, item.Name())
2020-12-26 15:22:39 +00:00
if item.IsDir() {
if err = p.processDir(filePath); err != nil {
lastError = err
}
2020-12-26 15:22:39 +00:00
continue
}
if err := p.processFile(filePath); err != nil {
lastError = err
2022-12-09 10:15:40 +00:00
logger.Error("conversion failed", zap.String("source", item.Name()), zap.Error(err))
2020-12-26 15:22:39 +00:00
}
}
if lastError != nil {
return fmt.Errorf("last error: %w", lastError)
}
2020-12-26 15:22:39 +00:00
return nil
}
2022-12-06 16:40:12 +00:00
func (p *processor) processFile(filePath string) error {
allDec := common.GetDecoder(filePath, p.skipNoopDecoder)
if len(allDec) == 0 {
return errors.New("skipping while no suitable decoder")
2022-12-06 16:40:12 +00:00
}
2024-09-04 21:51:54 +00:00
if err := p.process(filePath, allDec); err != nil {
return err
}
// if source file need to be removed
if p.removeSource {
err := os.RemoveAll(filePath)
if err != nil {
return err
}
logger.Info("source file removed after success conversion", zap.String("source", filePath))
}
return nil
2022-12-06 16:40:12 +00:00
}
func (p *processor) findDecoder(decoders []common.DecoderFactory, params *common.DecoderParams) (*common.Decoder, *common.DecoderFactory, error) {
for _, factory := range decoders {
dec := factory.Create(params)
err := dec.Validate()
if err == nil {
return &dec, &factory, nil
}
logger.Warn("try decode failed", zap.Error(err))
}
return nil, nil, errors.New("no any decoder can resolve the file")
}
func (p *processor) process(inputFile string, allDec []common.DecoderFactory) error {
file, err := os.Open(inputFile)
2020-12-26 15:22:39 +00:00
if err != nil {
return err
}
defer file.Close()
2022-12-05 17:32:13 +00:00
logger := logger.With(zap.String("source", inputFile))
2020-12-26 15:22:39 +00:00
pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
Reader: file,
Extension: filepath.Ext(inputFile),
FilePath: inputFile,
2022-12-05 17:32:13 +00:00
Logger: logger,
})
if err != nil {
return err
}
dec := *pDec
params := &ffmpeg.UpdateMetadataParams{}
header := bytes.NewBuffer(nil)
2022-12-06 13:47:33 +00:00
_, err = io.CopyN(header, dec, 64)
if err != nil {
return fmt.Errorf("read header failed: %w", err)
2020-12-26 15:22:39 +00:00
}
audio := io.MultiReader(header, dec)
params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
2022-02-24 15:32:17 +00:00
2022-12-06 16:40:12 +00:00
if p.updateMetadata {
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
2022-12-06 16:40:12 +00:00
// since ffmpeg doesn't support multiple input streams,
// we need to write the audio to a temp file.
// since qmc decoder doesn't support seeking & relying on ffmpeg probe, we need to read the whole file.
// TODO: support seeking or using pipe for qmc decoder.
params.Audio, err = utils.WriteTempFile(audio, params.AudioExt)
if err != nil {
return fmt.Errorf("updateAudioMeta write temp file: %w", err)
}
defer os.Remove(params.Audio)
2022-12-06 16:40:12 +00:00
params.Meta, err = audioMetaGetter.GetAudioMeta(ctx)
if err != nil {
2022-12-06 16:40:12 +00:00
logger.Warn("get audio meta failed", zap.Error(err))
}
if params.Meta == nil { // reset audio meta if failed
audio, err = os.Open(params.Audio)
if err != nil {
return fmt.Errorf("updateAudioMeta open temp file: %w", err)
}
}
2022-11-22 03:08:35 +00:00
}
}
2022-12-06 16:40:12 +00:00
if p.updateMetadata && params.Meta != nil {
if coverGetter, ok := dec.(common.CoverImageGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
2022-11-21 22:45:18 +00:00
if cover, err := coverGetter.GetCoverImage(ctx); err != nil {
logger.Warn("get cover image failed", zap.Error(err))
} else if imgExt, ok := sniff.ImageExtension(cover); !ok {
logger.Warn("sniff cover image type failed", zap.Error(err))
} else {
params.AlbumArtExt = imgExt
2022-12-06 17:37:53 +00:00
params.AlbumArt = cover
2022-11-21 22:45:18 +00:00
}
}
2022-11-21 23:08:10 +00:00
}
inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile))
if err != nil {
return fmt.Errorf("get relative dir failed: %w", err)
}
inFilename := strings.TrimSuffix(filepath.Base(inputFile), decoderFactory.Suffix)
outPath := filepath.Join(p.outputDir, inputRelDir, inFilename+params.AudioExt)
2022-12-25 11:26:11 +00:00
if !p.overwriteOutput {
_, err := os.Stat(outPath)
if err == nil {
logger.Warn("output file already exist, skip", zap.String("destination", outPath))
return nil
2022-12-25 11:26:11 +00:00
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat output file failed: %w", err)
}
}
if params.Meta == nil {
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer outFile.Close()
2022-12-06 16:40:12 +00:00
if _, err := io.Copy(outFile, audio); err != nil {
return err
}
} else {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
2022-12-06 17:37:53 +00:00
if err := ffmpeg.UpdateMeta(ctx, outPath, params); err != nil {
return err
}
}
2024-09-04 21:51:54 +00:00
logger.Info("successfully converted", zap.String("source", inputFile), zap.String("destination", outPath))
2020-12-26 15:22:39 +00:00
return nil
}