From fd6f830916e27eebbf704a2143c6da2ff74d7751 Mon Sep 17 00:00:00 2001 From: Unlock Music Dev Date: Tue, 6 Dec 2022 23:55:43 +0800 Subject: [PATCH] feat(meta): write album art & metadata into destination file --- algo/qmc/qmc.go | 6 +-- cmd/um/main.go | 96 +++++++++++++++++++++++--------------- internal/ffmpeg/ffmpeg.go | 73 +++++------------------------ internal/ffmpeg/ffprobe.go | 14 ++++-- internal/utils/temp.go | 24 ++++++++++ 5 files changed, 107 insertions(+), 106 deletions(-) create mode 100644 internal/utils/temp.go diff --git a/algo/qmc/qmc.go b/algo/qmc/qmc.go index 0ea6c81..d6c084d 100644 --- a/algo/qmc/qmc.go +++ b/algo/qmc/qmc.go @@ -30,14 +30,14 @@ type Decoder struct { songID int rawMetaExtra2 int - albumID int - albumMediaID string + albumID int + albumMediaID string // cache meta common.AudioMeta cover []byte embeddedCover bool // embeddedCover is true if the cover is embedded in the file - probeBuf *bytes.Buffer // probeBuf is the buffer for sniffing metadata + probeBuf *bytes.Buffer // probeBuf is the buffer for sniffing metadata, TODO: consider pipe? // provider logger *zap.Logger diff --git a/cmd/um/main.go b/cmd/um/main.go index 3037a04..211e3d8 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -25,8 +25,10 @@ import ( _ "unlock-music.dev/cli/algo/tm" _ "unlock-music.dev/cli/algo/xiami" _ "unlock-music.dev/cli/algo/ximalaya" + "unlock-music.dev/cli/internal/ffmpeg" "unlock-music.dev/cli/internal/logging" "unlock-music.dev/cli/internal/sniff" + "unlock-music.dev/cli/internal/utils" ) var AppVersion = "v0.0.6" @@ -187,60 +189,80 @@ func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFu return errors.New("no any decoder can resolve the file") } + params := &ffmpeg.UpdateMetadataParams{} + header := bytes.NewBuffer(nil) _, err = io.CopyN(header, dec, 64) if err != nil { return fmt.Errorf("read header failed: %w", err) } - - outExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") - inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) - - outPath := filepath.Join(outputDir, inFilename+outExt) - outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return err - } - defer outFile.Close() - - if _, err := io.Copy(outFile, header); err != nil { - return err - } - if _, err := io.Copy(outFile, dec); err != nil { - return err - } + audio := io.MultiReader(header, dec) + params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - meta, err := audioMetaGetter.GetAudioMeta(ctx) + // 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) + + params.Meta, err = audioMetaGetter.GetAudioMeta(ctx) if err != nil { logger.Warn("get audio meta failed", zap.Error(err)) - } else { - logger.Info("audio metadata", - zap.String("title", meta.GetTitle()), - zap.Strings("artists", meta.GetArtists()), - zap.String("album", meta.GetAlbum()), - ) + } + + 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) + } } } - if coverGetter, ok := dec.(common.CoverImageGetter); ok { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if params.Meta != nil { + if coverGetter, ok := dec.(common.CoverImageGetter); ok { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + 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 + params.AlbumArt = bytes.NewReader(cover) + } + } + } + + inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) + outPath := filepath.Join(outputDir, inFilename+params.AudioExt) + + 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() + + if _, err = io.Copy(outFile, audio); err != nil { + return err + } + outFile.Close() + + } else { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - cover, err := coverGetter.GetCoverImage(ctx) - if 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 { - coverPath := filepath.Join(outputDir, inFilename+imgExt) - err = os.WriteFile(coverPath, cover, 0644) - if err != nil { - logger.Warn("write cover image failed", zap.Error(err)) - } + if err := ffmpeg.UpdateAudioMetadata(ctx, outPath, params); err != nil { + return err } } diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index 316d3dd..467acd9 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -10,6 +10,7 @@ import ( "strings" "unlock-music.dev/cli/algo/common" + "unlock-music.dev/cli/internal/utils" ) func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { @@ -33,8 +34,8 @@ func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { } type UpdateMetadataParams struct { - Audio io.Reader // required - AudioExt string // required + Audio string // required + AudioExt string // required Meta common.AudioMeta // required @@ -42,24 +43,15 @@ type UpdateMetadataParams struct { AlbumArtExt string // required if AlbumArt is not nil } -func UpdateAudioMetadata(ctx context.Context, params *UpdateMetadataParams) (*bytes.Buffer, error) { +func UpdateAudioMetadata(ctx context.Context, outPath string, params *UpdateMetadataParams) error { builder := newFFmpegBuilder() - builder.SetFlag("y") // overwrite output file - out := newOutputBuilder("pipe:1") // use stdout as output - out.AddOption("f", encodeFormatFromExt(params.AudioExt)) // use mp3 muxer + out := newOutputBuilder(outPath) // output to file + builder.SetFlag("y") // overwrite output file builder.AddOutput(out) - // since ffmpeg doesn't support multiple input streams, - // we need to write the audio to a temp file - audioPath, err := writeTempFile(params.Audio, params.AudioExt) - if err != nil { - return nil, fmt.Errorf("updateAudioMeta write temp file: %w", err) - } - defer os.Remove(audioPath) - // input audio -> output audio - builder.AddInput(newInputBuilder(audioPath)) // input 0: audio + builder.AddInput(newInputBuilder(params.Audio)) // input 0: audio out.AddOption("map", "0:a") out.AddOption("codec:a", "copy") @@ -68,9 +60,9 @@ func UpdateAudioMetadata(ctx context.Context, params *UpdateMetadataParams) (*by params.AudioExt != ".wav" /* wav doesn't support attached image */ { // write cover to temp file - artPath, err := writeTempFile(params.AlbumArt, params.AlbumArtExt) + artPath, err := utils.WriteTempFile(params.AlbumArt, params.AlbumArtExt) if err != nil { - return nil, fmt.Errorf("updateAudioMeta write temp file: %w", err) + return fmt.Errorf("updateAudioMeta write temp file: %w", err) } defer os.Remove(artPath) @@ -108,51 +100,10 @@ func UpdateAudioMetadata(ctx context.Context, params *UpdateMetadataParams) (*by // execute ffmpeg cmd := builder.Command(ctx) - stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} - cmd.Stdout, cmd.Stderr = stdout, stderr - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("ffmpeg run: %w", err) + if stdout, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout)) } - return stdout, nil -} - -func writeTempFile(rd io.Reader, ext string) (string, error) { - audioFile, err := os.CreateTemp("", "*"+ext) - if err != nil { - return "", fmt.Errorf("ffmpeg create temp file: %w", err) - } - - if _, err := io.Copy(audioFile, rd); err != nil { - return "", fmt.Errorf("ffmpeg write temp file: %w", err) - } - - if err := audioFile.Close(); err != nil { - return "", fmt.Errorf("ffmpeg close temp file: %w", err) - } - - return audioFile.Name(), nil -} - -// encodeFormatFromExt returns the file format name the recognized & supporting encoding by ffmpeg. -func encodeFormatFromExt(ext string) string { - switch ext { - case ".flac": - return "flac" // raw FLAC - case ".mp3": - return "mp3" // MP3 (MPEG audio layer 3) - case ".ogg": - return "ogg" // Ogg - case ".m4a": - return "ipod" // iPod H.264 MP4 (MPEG-4 Part 14) - case ".wav": - return "wav" // WAV / WAVE (Waveform Audio) - case ".aac": - return "adts" // ADTS AAC (Advanced Audio Coding) - case ".wma": - return "asf" // ASF (Advanced / Active Streaming Format) - default: - return "" - } + return nil } diff --git a/internal/ffmpeg/ffprobe.go b/internal/ffmpeg/ffprobe.go index b328fb0..a79a92e 100644 --- a/internal/ffmpeg/ffprobe.go +++ b/internal/ffmpeg/ffprobe.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "bytes" "context" "encoding/json" "io" @@ -119,17 +120,20 @@ func ProbeReader(ctx context.Context, rd io.Reader) (*Result, error) { cmd := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", // disable logging "-print_format", "json", // use json format - "-show_format", "-show_streams", // retrieve format and streams - "-", // input from stdin + "-show_format", "-show_streams", "-show_error", // retrieve format and streams + "pipe:0", // input from stdin ) + cmd.Stdin = rd - out, err := cmd.Output() - if err != nil { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = stdout, stderr + + if err := cmd.Run(); err != nil { return nil, err } ret := new(Result) - if err := json.Unmarshal(out, ret); err != nil { + if err := json.Unmarshal(stdout.Bytes(), ret); err != nil { return nil, err } diff --git a/internal/utils/temp.go b/internal/utils/temp.go new file mode 100644 index 0000000..6e9cfe0 --- /dev/null +++ b/internal/utils/temp.go @@ -0,0 +1,24 @@ +package utils + +import ( + "fmt" + "io" + "os" +) + +func WriteTempFile(rd io.Reader, ext string) (string, error) { + audioFile, err := os.CreateTemp("", "*"+ext) + if err != nil { + return "", fmt.Errorf("ffmpeg create temp file: %w", err) + } + + if _, err := io.Copy(audioFile, rd); err != nil { + return "", fmt.Errorf("ffmpeg write temp file: %w", err) + } + + if err := audioFile.Close(); err != nil { + return "", fmt.Errorf("ffmpeg close temp file: %w", err) + } + + return audioFile.Name(), nil +}