cli/internal/ffmpeg/ffmpeg.go

159 lines
4.2 KiB
Go

package ffmpeg
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"unlock-music.dev/cli/algo/common"
)
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
"-codec:v", "copy", // copy video(image) codec
"-f", "image2", // use image2 muxer
"pipe:1", // output to stdout
)
cmd.Stdin = rd
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
}
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 ""
}
}