feat(meta): write album art & metadata into destination file

pull/43/head
Unlock Music Dev 2 years ago
parent 02e065aac4
commit fd6f830916
No known key found for this signature in database
GPG Key ID: 95202E10D3413A1D

@ -37,7 +37,7 @@ type Decoder struct {
meta common.AudioMeta meta common.AudioMeta
cover []byte cover []byte
embeddedCover bool // embeddedCover is true if the cover is embedded in the file 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 // provider
logger *zap.Logger logger *zap.Logger

@ -25,8 +25,10 @@ import (
_ "unlock-music.dev/cli/algo/tm" _ "unlock-music.dev/cli/algo/tm"
_ "unlock-music.dev/cli/algo/xiami" _ "unlock-music.dev/cli/algo/xiami"
_ "unlock-music.dev/cli/algo/ximalaya" _ "unlock-music.dev/cli/algo/ximalaya"
"unlock-music.dev/cli/internal/ffmpeg"
"unlock-music.dev/cli/internal/logging" "unlock-music.dev/cli/internal/logging"
"unlock-music.dev/cli/internal/sniff" "unlock-music.dev/cli/internal/sniff"
"unlock-music.dev/cli/internal/utils"
) )
var AppVersion = "v0.0.6" 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") return errors.New("no any decoder can resolve the file")
} }
params := &ffmpeg.UpdateMetadataParams{}
header := bytes.NewBuffer(nil) header := bytes.NewBuffer(nil)
_, err = io.CopyN(header, dec, 64) _, err = io.CopyN(header, dec, 64)
if err != nil { if err != nil {
return fmt.Errorf("read header failed: %w", err) return fmt.Errorf("read header failed: %w", err)
} }
audio := io.MultiReader(header, dec)
params.AudioExt = sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
outExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3") if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
inFilename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
outPath := filepath.Join(outputDir, inFilename+outExt) // since ffmpeg doesn't support multiple input streams,
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) // 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 { if err != nil {
return err return fmt.Errorf("updateAudioMeta write temp file: %w", err)
} }
defer outFile.Close() defer os.Remove(params.Audio)
if _, err := io.Copy(outFile, header); err != nil { params.Meta, err = audioMetaGetter.GetAudioMeta(ctx)
return err if err != nil {
} logger.Warn("get audio meta failed", zap.Error(err))
if _, err := io.Copy(outFile, dec); err != nil {
return err
} }
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok { if params.Meta == nil { // reset audio meta if failed
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) audio, err = os.Open(params.Audio)
defer cancel()
meta, err := audioMetaGetter.GetAudioMeta(ctx)
if err != nil { if err != nil {
logger.Warn("get audio meta failed", zap.Error(err)) return fmt.Errorf("updateAudioMeta open temp file: %w", 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 {
if coverGetter, ok := dec.(common.CoverImageGetter); ok { if coverGetter, ok := dec.(common.CoverImageGetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
cover, err := coverGetter.GetCoverImage(ctx) if cover, err := coverGetter.GetCoverImage(ctx); err != nil {
if err != nil {
logger.Warn("get cover image failed", zap.Error(err)) logger.Warn("get cover image failed", zap.Error(err))
} else if imgExt, ok := sniff.ImageExtension(cover); !ok { } else if imgExt, ok := sniff.ImageExtension(cover); !ok {
logger.Warn("sniff cover image type failed", zap.Error(err)) logger.Warn("sniff cover image type failed", zap.Error(err))
} else { } else {
coverPath := filepath.Join(outputDir, inFilename+imgExt) params.AlbumArtExt = imgExt
err = os.WriteFile(coverPath, cover, 0644) 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 { if err != nil {
logger.Warn("write cover image failed", zap.Error(err)) 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()
if err := ffmpeg.UpdateAudioMetadata(ctx, outPath, params); err != nil {
return err
} }
} }

@ -10,6 +10,7 @@ import (
"strings" "strings"
"unlock-music.dev/cli/algo/common" "unlock-music.dev/cli/algo/common"
"unlock-music.dev/cli/internal/utils"
) )
func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) { func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) {
@ -33,7 +34,7 @@ func ExtractAlbumArt(ctx context.Context, rd io.Reader) (*bytes.Buffer, error) {
} }
type UpdateMetadataParams struct { type UpdateMetadataParams struct {
Audio io.Reader // required Audio string // required
AudioExt string // required AudioExt string // required
Meta common.AudioMeta // required Meta common.AudioMeta // required
@ -42,24 +43,15 @@ type UpdateMetadataParams struct {
AlbumArtExt string // required if AlbumArt is not nil 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 := newFFmpegBuilder()
builder.SetFlag("y") // overwrite output file
out := newOutputBuilder("pipe:1") // use stdout as output out := newOutputBuilder(outPath) // output to file
out.AddOption("f", encodeFormatFromExt(params.AudioExt)) // use mp3 muxer builder.SetFlag("y") // overwrite output file
builder.AddOutput(out) 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 // 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("map", "0:a")
out.AddOption("codec:a", "copy") 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 */ { params.AudioExt != ".wav" /* wav doesn't support attached image */ {
// write cover to temp file // write cover to temp file
artPath, err := writeTempFile(params.AlbumArt, params.AlbumArtExt) artPath, err := utils.WriteTempFile(params.AlbumArt, params.AlbumArtExt)
if err != nil { 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) defer os.Remove(artPath)
@ -108,51 +100,10 @@ func UpdateAudioMetadata(ctx context.Context, params *UpdateMetadataParams) (*by
// execute ffmpeg // execute ffmpeg
cmd := builder.Command(ctx) 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 if stdout, err := cmd.CombinedOutput(); err != nil {
} return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout))
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 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 ""
}
} }

@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
@ -119,17 +120,20 @@ func ProbeReader(ctx context.Context, rd io.Reader) (*Result, error) {
cmd := exec.CommandContext(ctx, "ffprobe", cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "quiet", // disable logging "-v", "quiet", // disable logging
"-print_format", "json", // use json format "-print_format", "json", // use json format
"-show_format", "-show_streams", // retrieve format and streams "-show_format", "-show_streams", "-show_error", // retrieve format and streams
"-", // input from stdin "pipe:0", // input from stdin
) )
cmd.Stdin = rd cmd.Stdin = rd
out, err := cmd.Output() stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
if err != nil { cmd.Stdout, cmd.Stderr = stdout, stderr
if err := cmd.Run(); err != nil {
return nil, err return nil, err
} }
ret := new(Result) ret := new(Result)
if err := json.Unmarshal(out, ret); err != nil { if err := json.Unmarshal(stdout.Bytes(), ret); err != nil {
return nil, err return nil, err
} }

@ -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
}
Loading…
Cancel
Save