main.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. package main
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "github.com/fsnotify/fsnotify"
  8. "github.com/urfave/cli/v2"
  9. "go.uber.org/zap"
  10. "go.uber.org/zap/zapcore"
  11. "io"
  12. "os"
  13. "os/signal"
  14. "path/filepath"
  15. "runtime"
  16. "runtime/debug"
  17. "sort"
  18. "strings"
  19. "time"
  20. "unlock-music.dev/cli/algo/common"
  21. _ "unlock-music.dev/cli/algo/kgm"
  22. _ "unlock-music.dev/cli/algo/kwm"
  23. _ "unlock-music.dev/cli/algo/ncm"
  24. "unlock-music.dev/cli/algo/qmc"
  25. _ "unlock-music.dev/cli/algo/tm"
  26. _ "unlock-music.dev/cli/algo/xiami"
  27. _ "unlock-music.dev/cli/algo/ximalaya"
  28. "unlock-music.dev/cli/internal/ffmpeg"
  29. "unlock-music.dev/cli/internal/sniff"
  30. "unlock-music.dev/cli/internal/utils"
  31. )
  32. var AppVersion = "v0.2.11"
  33. var logger = setupLogger(false) // TODO: inject logger to application, instead of using global logger
  34. func main() {
  35. module, ok := debug.ReadBuildInfo()
  36. if ok && module.Main.Version != "(devel)" {
  37. AppVersion = module.Main.Version
  38. }
  39. app := cli.App{
  40. Name: "Unlock Music CLI",
  41. HelpName: "um",
  42. Usage: "Unlock your encrypted music file https://git.unlock-music.dev/um/cli",
  43. Version: fmt.Sprintf("%s (%s,%s/%s)", AppVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH),
  44. Flags: []cli.Flag{
  45. &cli.StringFlag{Name: "input", Aliases: []string{"i"}, Usage: "path to input file or dir", Required: false},
  46. &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "path to output dir", Required: false},
  47. &cli.StringFlag{Name: "qmc-mmkv", Aliases: []string{"db"}, Usage: "path to qmc mmkv (.crc file also required)", Required: false},
  48. &cli.StringFlag{Name: "qmc-mmkv-key", Aliases: []string{"key"}, Usage: "mmkv password (16 ascii chars)", Required: false},
  49. &cli.BoolFlag{Name: "remove-source", Aliases: []string{"rs"}, Usage: "remove source file", Required: false, Value: false},
  50. &cli.BoolFlag{Name: "skip-noop", Aliases: []string{"n"}, Usage: "skip noop decoder", Required: false, Value: true},
  51. &cli.BoolFlag{Name: "verbose", Aliases: []string{"V"}, Usage: "verbose logging", Required: false, Value: false},
  52. &cli.BoolFlag{Name: "update-metadata", Usage: "update metadata & album art from network", Required: false, Value: false},
  53. &cli.BoolFlag{Name: "overwrite", Usage: "overwrite output file without asking", Required: false, Value: false},
  54. &cli.BoolFlag{Name: "watch", Usage: "watch the input dir and process new files", Required: false, Value: false},
  55. &cli.BoolFlag{Name: "supported-ext", Usage: "show supported file extensions and exit", Required: false, Value: false},
  56. },
  57. Action: appMain,
  58. Copyright: fmt.Sprintf("Copyright (c) 2020 - %d Unlock Music https://git.unlock-music.dev/um/cli/src/branch/master/LICENSE", time.Now().Year()),
  59. HideHelpCommand: true,
  60. UsageText: "um [-o /path/to/output/dir] [--extra-flags] [-i] /path/to/input",
  61. }
  62. err := app.Run(os.Args)
  63. if err != nil {
  64. logger.Fatal("run app failed", zap.Error(err))
  65. }
  66. }
  67. func printSupportedExtensions() {
  68. var exts []string
  69. extSet := make(map[string]int)
  70. for _, factory := range common.DecoderRegistry {
  71. ext := strings.TrimPrefix(factory.Suffix, ".")
  72. if n, ok := extSet[ext]; ok {
  73. extSet[ext] = n + 1
  74. } else {
  75. extSet[ext] = 1
  76. }
  77. }
  78. for ext := range extSet {
  79. exts = append(exts, ext)
  80. }
  81. sort.Strings(exts)
  82. for _, ext := range exts {
  83. fmt.Printf("%s: %d\n", ext, extSet[ext])
  84. }
  85. }
  86. func setupLogger(verbose bool) *zap.Logger {
  87. logConfig := zap.NewProductionEncoderConfig()
  88. logConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
  89. logConfig.EncodeTime = zapcore.RFC3339TimeEncoder
  90. enabler := zap.LevelEnablerFunc(func(level zapcore.Level) bool {
  91. if verbose {
  92. return true
  93. }
  94. return level >= zapcore.InfoLevel
  95. })
  96. return zap.New(zapcore.NewCore(
  97. zapcore.NewConsoleEncoder(logConfig),
  98. os.Stdout,
  99. enabler,
  100. ))
  101. }
  102. func appMain(c *cli.Context) (err error) {
  103. logger = setupLogger(c.Bool("verbose"))
  104. cwd, err := os.Getwd()
  105. if err != nil {
  106. return err
  107. }
  108. if c.Bool("supported-ext") {
  109. printSupportedExtensions()
  110. return nil
  111. }
  112. input := c.String("input")
  113. if input == "" {
  114. switch c.Args().Len() {
  115. case 0:
  116. input = cwd
  117. case 1:
  118. input = c.Args().Get(0)
  119. default:
  120. return errors.New("please specify input file (or directory)")
  121. }
  122. }
  123. input, absErr := filepath.Abs(input)
  124. if absErr != nil {
  125. return fmt.Errorf("get abs path failed: %w", absErr)
  126. }
  127. output := c.String("output")
  128. inputStat, err := os.Stat(input)
  129. if err != nil {
  130. return err
  131. }
  132. var inputDir string
  133. if inputStat.IsDir() {
  134. inputDir = input
  135. } else {
  136. inputDir = filepath.Dir(input)
  137. }
  138. inputDir, absErr = filepath.Abs(inputDir)
  139. if absErr != nil {
  140. return fmt.Errorf("get abs path (inputDir) failed: %w", absErr)
  141. }
  142. if output == "" {
  143. // Default to where the input dir is
  144. output = inputDir
  145. }
  146. logger.Debug("resolve input/output path", zap.String("inputDir", inputDir), zap.String("input", input), zap.String("output", output))
  147. outputStat, err := os.Stat(output)
  148. if err != nil {
  149. if errors.Is(err, os.ErrNotExist) {
  150. err = os.MkdirAll(output, 0755)
  151. }
  152. if err != nil {
  153. return err
  154. }
  155. } else if !outputStat.IsDir() {
  156. return errors.New("output should be a writable directory")
  157. }
  158. if mmkv := c.String("qmc-mmkv"); mmkv != "" {
  159. // If key is not set, the mmkv vault will be treated as unencrypted.
  160. key := c.String("qmc-mmkv-key")
  161. err := qmc.OpenMMKV(mmkv, key, logger)
  162. if err != nil {
  163. return err
  164. }
  165. }
  166. proc := &processor{
  167. logger: logger,
  168. inputDir: inputDir,
  169. outputDir: output,
  170. skipNoopDecoder: c.Bool("skip-noop"),
  171. removeSource: c.Bool("remove-source"),
  172. updateMetadata: c.Bool("update-metadata"),
  173. overwriteOutput: c.Bool("overwrite"),
  174. }
  175. if inputStat.IsDir() {
  176. watchDir := c.Bool("watch")
  177. if !watchDir {
  178. return proc.processDir(input)
  179. } else {
  180. return proc.watchDir(input)
  181. }
  182. } else {
  183. return proc.processFile(input)
  184. }
  185. }
  186. type processor struct {
  187. logger *zap.Logger
  188. inputDir string
  189. outputDir string
  190. skipNoopDecoder bool
  191. removeSource bool
  192. updateMetadata bool
  193. overwriteOutput bool
  194. }
  195. func (p *processor) watchDir(inputDir string) error {
  196. if err := p.processDir(inputDir); err != nil {
  197. return err
  198. }
  199. watcher, err := fsnotify.NewWatcher()
  200. if err != nil {
  201. return fmt.Errorf("failed to create watcher: %w", err)
  202. }
  203. defer watcher.Close()
  204. go func() {
  205. for {
  206. select {
  207. case event, ok := <-watcher.Events:
  208. if !ok {
  209. return
  210. }
  211. if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
  212. // try open with exclusive mode, to avoid file is still writing
  213. f, err := os.OpenFile(event.Name, os.O_RDONLY, os.ModeExclusive)
  214. if err != nil {
  215. logger.Debug("failed to open file exclusively", zap.String("path", event.Name), zap.Error(err))
  216. time.Sleep(1 * time.Second) // wait for file writing complete
  217. continue
  218. }
  219. _ = f.Close()
  220. if err := p.processFile(event.Name); err != nil {
  221. logger.Warn("failed to process file", zap.String("path", event.Name), zap.Error(err))
  222. }
  223. }
  224. case err, ok := <-watcher.Errors:
  225. if !ok {
  226. return
  227. }
  228. logger.Error("file watcher got error", zap.Error(err))
  229. }
  230. }
  231. }()
  232. err = watcher.Add(inputDir)
  233. if err != nil {
  234. return fmt.Errorf("failed to watch dir %s: %w", inputDir, err)
  235. }
  236. signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
  237. defer stop()
  238. <-signalCtx.Done()
  239. return nil
  240. }
  241. func (p *processor) processDir(inputDir string) error {
  242. items, err := os.ReadDir(inputDir)
  243. if err != nil {
  244. return err
  245. }
  246. var lastError error = nil
  247. for _, item := range items {
  248. filePath := filepath.Join(inputDir, item.Name())
  249. if item.IsDir() {
  250. if err = p.processDir(filePath); err != nil {
  251. lastError = err
  252. }
  253. continue
  254. }
  255. if err := p.processFile(filePath); err != nil {
  256. lastError = err
  257. logger.Error("conversion failed", zap.String("source", item.Name()), zap.Error(err))
  258. }
  259. }
  260. if lastError != nil {
  261. return fmt.Errorf("last error: %w", lastError)
  262. }
  263. return nil
  264. }
  265. func (p *processor) processFile(filePath string) error {
  266. p.logger.Debug("processFile", zap.String("file", filePath), zap.String("inputDir", p.inputDir))
  267. allDec := common.GetDecoder(filePath, p.skipNoopDecoder)
  268. if len(allDec) == 0 {
  269. return errors.New("skipping while no suitable decoder")
  270. }
  271. if err := p.process(filePath, allDec); err != nil {
  272. return err
  273. }
  274. // if source file need to be removed
  275. if p.removeSource {
  276. err := os.RemoveAll(filePath)
  277. if err != nil {
  278. return err
  279. }
  280. logger.Info("source file removed after success conversion", zap.String("source", filePath))
  281. }
  282. return nil
  283. }
  284. func (p *processor) findDecoder(decoders []common.DecoderFactory, params *common.DecoderParams) (*common.Decoder, *common.DecoderFactory, error) {
  285. for _, factory := range decoders {
  286. dec := factory.Create(params)
  287. err := dec.Validate()
  288. if err == nil {
  289. return &dec, &factory, nil
  290. }
  291. logger.Warn("try decode failed", zap.Error(err))
  292. }
  293. return nil, nil, errors.New("no any decoder can resolve the file")
  294. }
  295. func (p *processor) process(inputFile string, allDec []common.DecoderFactory) error {
  296. file, err := os.Open(inputFile)
  297. if err != nil {
  298. return err
  299. }
  300. defer file.Close()
  301. logger := logger.With(zap.String("source", inputFile))
  302. pDec, decoderFactory, err := p.findDecoder(allDec, &common.DecoderParams{
  303. Reader: file,
  304. Extension: filepath.Ext(inputFile),
  305. FilePath: inputFile,
  306. Logger: logger,
  307. })
  308. if err != nil {
  309. return err
  310. }
  311. dec := *pDec
  312. params := &ffmpeg.UpdateMetadataParams{}
  313. header := bytes.NewBuffer(nil)
  314. _, err = io.CopyN(header, dec, 64)
  315. if err != nil {
  316. return fmt.Errorf("read header failed: %w", err)
  317. }
  318. audio := io.MultiReader(header, dec)
  319. params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
  320. if p.updateMetadata {
  321. if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
  322. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  323. defer cancel()
  324. // since ffmpeg doesn't support multiple input streams,
  325. // we need to write the audio to a temp file.
  326. // since qmc decoder doesn't support seeking & relying on ffmpeg probe, we need to read the whole file.
  327. // TODO: support seeking or using pipe for qmc decoder.
  328. params.Audio, err = utils.WriteTempFile(audio, params.AudioExt)
  329. if err != nil {
  330. return fmt.Errorf("updateAudioMeta write temp file: %w", err)
  331. }
  332. defer os.Remove(params.Audio)
  333. params.Meta, err = audioMetaGetter.GetAudioMeta(ctx)
  334. if err != nil {
  335. logger.Warn("get audio meta failed", zap.Error(err))
  336. }
  337. if params.Meta == nil { // reset audio meta if failed
  338. audio, err = os.Open(params.Audio)
  339. if err != nil {
  340. return fmt.Errorf("updateAudioMeta open temp file: %w", err)
  341. }
  342. }
  343. }
  344. }
  345. if p.updateMetadata && params.Meta != nil {
  346. if coverGetter, ok := dec.(common.CoverImageGetter); ok {
  347. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  348. defer cancel()
  349. if cover, err := coverGetter.GetCoverImage(ctx); err != nil {
  350. logger.Warn("get cover image failed", zap.Error(err))
  351. } else if imgExt, ok := sniff.ImageExtension(cover); !ok {
  352. logger.Warn("sniff cover image type failed", zap.Error(err))
  353. } else {
  354. params.AlbumArtExt = imgExt
  355. params.AlbumArt = cover
  356. }
  357. }
  358. }
  359. inputRelDir, err := filepath.Rel(p.inputDir, filepath.Dir(inputFile))
  360. if err != nil {
  361. return fmt.Errorf("get relative dir failed: %w", err)
  362. }
  363. inFilename := strings.TrimSuffix(filepath.Base(inputFile), decoderFactory.Suffix)
  364. outPath := filepath.Join(p.outputDir, inputRelDir, inFilename+params.AudioExt)
  365. if !p.overwriteOutput {
  366. _, err := os.Stat(outPath)
  367. if err == nil {
  368. logger.Warn("output file already exist, skip", zap.String("destination", outPath))
  369. return nil
  370. } else if !errors.Is(err, os.ErrNotExist) {
  371. return fmt.Errorf("stat output file failed: %w", err)
  372. }
  373. }
  374. if params.Meta == nil {
  375. outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
  376. if err != nil {
  377. return err
  378. }
  379. defer outFile.Close()
  380. if _, err := io.Copy(outFile, audio); err != nil {
  381. return err
  382. }
  383. } else {
  384. ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
  385. defer cancel()
  386. if err := ffmpeg.UpdateMeta(ctx, outPath, params, logger); err != nil {
  387. return err
  388. }
  389. }
  390. logger.Info("successfully converted", zap.String("source", inputFile), zap.String("destination", outPath))
  391. return nil
  392. }