feat(meta): add writing metadata by ffmpeg
This commit is contained in:
parent
8319df6ca3
commit
02e065aac4
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"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)
|
return nil, fmt.Errorf("qmc[GetCoverImage] extract album art: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
d.cover, err = io.ReadAll(img)
|
d.cover = img.Bytes()
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("qmc[GetCoverImage] read embed cover: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.cover, nil
|
return d.cover, nil
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"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",
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||||
"-i", "pipe:0", // input from stdin
|
"-i", "pipe:0", // input from stdin
|
||||||
"-an", // disable audio
|
"-an", // disable audio
|
||||||
@ -21,13 +25,134 @@ func ExtractAlbumArt(ctx context.Context, rd io.Reader) (io.Reader, error) {
|
|||||||
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
||||||
cmd.Stdout, cmd.Stderr = stdout, stderr
|
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)
|
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
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
131
internal/ffmpeg/options.go
Normal file
131
internal/ffmpeg/options.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user