From 02e065aac447ed0e4c21ea3c9883b79edb6d4fda Mon Sep 17 00:00:00 2001 From: Unlock Music Dev Date: Tue, 6 Dec 2022 04:27:44 +0800 Subject: [PATCH] feat(meta): add writing metadata by ffmpeg --- algo/qmc/qmc_meta.go | 6 +- internal/ffmpeg/ffmpeg.go | 137 +++++++++++++++++++++++++++++++++++-- internal/ffmpeg/options.go | 131 +++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 internal/ffmpeg/options.go diff --git a/algo/qmc/qmc_meta.go b/algo/qmc/qmc_meta.go index 6a93b32..d034faf 100644 --- a/algo/qmc/qmc_meta.go +++ b/algo/qmc/qmc_meta.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "strings" "github.com/samber/lo" @@ -102,10 +101,7 @@ func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) { return nil, fmt.Errorf("qmc[GetCoverImage] extract album art: %w", err) } - d.cover, err = io.ReadAll(img) - if err != nil { - return nil, fmt.Errorf("qmc[GetCoverImage] read embed cover: %w", err) - } + d.cover = img.Bytes() return d.cover, nil } diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index 2487244..316d3dd 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -5,10 +5,14 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "strings" + + "unlock-music.dev/cli/algo/common" ) -func ExtractAlbumArt(ctx context.Context, rd io.Reader) (io.Reader, error) { +func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { cmd := exec.CommandContext(ctx, "ffmpeg", "-i", "pipe:0", // input from stdin "-an", // disable audio @@ -21,13 +25,134 @@ func ExtractAlbumArt(ctx context.Context, rd io.Reader) (io.Reader, error) { stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} cmd.Stdout, cmd.Stderr = stdout, stderr - if err := cmd.Start(); err != nil { + if err := cmd.Run(); err != nil { return nil, fmt.Errorf("ffmpeg run: %w", err) } - if err := cmd.Wait(); err != nil { - return nil, fmt.Errorf("ffmpeg wait: %w: %s", err, stderr.String()) - } - return stdout, nil } + +type UpdateMetadataParams struct { + Audio io.Reader // required + AudioExt string // required + + Meta common.AudioMeta // required + + AlbumArt io.Reader // optional + AlbumArtExt string // required if AlbumArt is not nil +} + +func UpdateAudioMetadata(ctx context.Context, params *UpdateMetadataParams) (*bytes.Buffer, 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 + 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 + out.AddOption("map", "0:a") + out.AddOption("codec:a", "copy") + + // input cover -> output cover + if params.AlbumArt != nil && + params.AudioExt != ".wav" /* wav doesn't support attached image */ { + + // write cover to temp file + artPath, err := writeTempFile(params.AlbumArt, params.AlbumArtExt) + if err != nil { + return nil, fmt.Errorf("updateAudioMeta write temp file: %w", err) + } + defer os.Remove(artPath) + + builder.AddInput(newInputBuilder(artPath)) // input 1: cover + out.AddOption("map", "1:v") + + switch params.AudioExt { + case ".ogg": // ogg only supports theora codec + out.AddOption("codec:v", "libtheora") + case ".m4a": // .m4a(mp4) requires set codec, disposition, stream metadata + out.AddOption("codec:v", "mjpeg") + out.AddOption("disposition:v", "attached_pic") + out.AddMetadata("s:v", "title", "Album cover") + out.AddMetadata("s:v", "comment", "Cover (front)") + default: // other formats use default behavior + } + } + + // set file metadata + album := params.Meta.GetAlbum() + if album != "" { + out.AddMetadata("", "album", album) + } + + title := params.Meta.GetTitle() + if album != "" { + out.AddMetadata("", "title", title) + } + + artists := params.Meta.GetArtists() + if len(artists) != 0 { + // TODO: it seems that ffmpeg doesn't support multiple artists + out.AddMetadata("", "artist", strings.Join(artists, " / ")) + } + + // 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) + } + + 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 "" + } +} diff --git a/internal/ffmpeg/options.go b/internal/ffmpeg/options.go new file mode 100644 index 0000000..d7aeafd --- /dev/null +++ b/internal/ffmpeg/options.go @@ -0,0 +1,131 @@ +package ffmpeg + +import ( + "context" + "os/exec" + "strings" +) + +type ffmpegBuilder struct { + binary string // ffmpeg binary path + options map[string]string // global options + inputs []*inputBuilder // input options + outputs []*outputBuilder // output options +} + +func newFFmpegBuilder() *ffmpegBuilder { + return &ffmpegBuilder{ + binary: "ffmpeg", + options: make(map[string]string), + } +} + +func (b *ffmpegBuilder) AddInput(src *inputBuilder) { + b.inputs = append(b.inputs, src) +} + +func (b *ffmpegBuilder) AddOutput(dst *outputBuilder) { + b.outputs = append(b.outputs, dst) +} + +func (b *ffmpegBuilder) SetBinary(bin string) { + b.binary = bin +} + +func (b *ffmpegBuilder) SetFlag(flag string) { + b.options[flag] = "" +} + +func (b *ffmpegBuilder) SetOption(name, value string) { + b.options[name] = value +} + +func (b *ffmpegBuilder) Args() (args []string) { + for name, val := range b.options { + args = append(args, "-"+name) + if val != "" { + args = append(args, val) + } + } + + for _, input := range b.inputs { + args = append(args, input.Args()...) + } + for _, output := range b.outputs { + args = append(args, output.Args()...) + } + + return +} + +func (b *ffmpegBuilder) Command(ctx context.Context) *exec.Cmd { + bin := "ffmpeg" + if b.binary != "" { + bin = b.binary + } + + return exec.CommandContext(ctx, bin, b.Args()...) +} + +// inputBuilder is the builder for ffmpeg input options +type inputBuilder struct { + path string + options map[string][]string +} + +func newInputBuilder(path string) *inputBuilder { + return &inputBuilder{ + path: path, + options: make(map[string][]string), + } +} + +func (b *inputBuilder) AddOption(name, value string) { + b.options[name] = append(b.options[name], value) +} + +func (b *inputBuilder) Args() (args []string) { + for name, values := range b.options { + for _, val := range values { + args = append(args, "-"+name, val) + } + } + return append(args, "-i", b.path) +} + +// outputBuilder is the builder for ffmpeg output options +type outputBuilder struct { + path string + options map[string][]string +} + +func newOutputBuilder(path string) *outputBuilder { + return &outputBuilder{ + path: path, + options: make(map[string][]string), + } +} + +func (b *outputBuilder) AddOption(name, value string) { + b.options[name] = append(b.options[name], value) +} + +func (b *outputBuilder) Args() (args []string) { + for name, values := range b.options { + for _, val := range values { + args = append(args, "-"+name, val) + } + } + return append(args, b.path) +} + +// AddMetadata is the shortcut for adding "metadata" option +func (b *outputBuilder) AddMetadata(stream, key, value string) { + optVal := strings.TrimSpace(key) + "=" + strings.TrimSpace(value) + + if stream != "" { + b.AddOption("metadata:"+stream, optVal) + } else { + b.AddOption("metadata", optVal) + } +}