Feat: 获取&写入 音频文件的 Metadata #43
@ -30,14 +30,14 @@ type Decoder struct {
|
|||||||
songID int
|
songID int
|
||||||
rawMetaExtra2 int
|
rawMetaExtra2 int
|
||||||
|
|
||||||
albumID int
|
albumID int
|
||||||
albumMediaID string
|
albumMediaID string
|
||||||
|
|
||||||
// cache
|
// cache
|
||||||
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)
|
||||||
outExt := sniff.AudioExtensionWithFallback(header.Bytes(), ".mp3")
|
params.AudioExt = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
|
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
logger.Warn("get audio meta failed", zap.Error(err))
|
logger.Warn("get audio meta failed", zap.Error(err))
|
||||||
} else {
|
}
|
||||||
logger.Info("audio metadata",
|
|
||||||
zap.String("title", meta.GetTitle()),
|
if params.Meta == nil { // reset audio meta if failed
|
||||||
zap.Strings("artists", meta.GetArtists()),
|
audio, err = os.Open(params.Audio)
|
||||||
zap.String("album", meta.GetAlbum()),
|
if err != nil {
|
||||||
)
|
return fmt.Errorf("updateAudioMeta open temp file: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if coverGetter, ok := dec.(common.CoverImageGetter); ok {
|
if params.Meta != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
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()
|
defer cancel()
|
||||||
|
|
||||||
cover, err := coverGetter.GetCoverImage(ctx)
|
if err := ffmpeg.UpdateAudioMetadata(ctx, outPath, params); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,8 +34,8 @@ 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 {
|
if stdout, err := cmd.CombinedOutput(); err != nil {
|
||||||
return nil, fmt.Errorf("ffmpeg run: %w", err)
|
return fmt.Errorf("ffmpeg run: %w, %s", err, string(stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout, nil
|
return 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 ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
24
internal/utils/temp.go
Normal file
24
internal/utils/temp.go
Normal file
@ -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…
Reference in New Issue
Block a user