Feat: 获取&写入 音频文件的 Metadata #43
151
algo/qmc/client/base.go
Normal file
151
algo/qmc/client/base.go
Normal file
@ -0,0 +1,151 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const endpointURL = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
|
||||
type QQMusic struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (c *QQMusic) doRequest(ctx context.Context, reqBody any) ([]byte, error) {
|
||||
reqBodyBuf, ok := reqBody.([]byte)
|
||||
if !ok {
|
||||
var err error
|
||||
reqBodyBuf, err = json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[doRequest] marshal request: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
endpointURL+fmt.Sprintf("?pcachetime=%d", time.Now().Unix()),
|
||||
bytes.NewReader(reqBodyBuf),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[doRequest] create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "zh-CN")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
|
||||
reqp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[doRequest] send request: %w", err)
|
||||
}
|
||||
defer reqp.Body.Close()
|
||||
|
||||
respBodyBuf, err := io.ReadAll(reqp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[doRequest] read response: %w", err)
|
||||
}
|
||||
|
||||
return respBodyBuf, nil
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
Method string `json:"method"`
|
||||
Module string `json:"module"`
|
||||
Param any `json:"param"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
Code int `json:"code"`
|
||||
Ts int64 `json:"ts"`
|
||||
StartTs int64 `json:"start_ts"`
|
||||
TraceID string `json:"traceid"`
|
||||
}
|
||||
|
||||
type rpcSubResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *QQMusic) rpcCall(ctx context.Context,
|
||||
protocol string, method string, module string,
|
||||
param any,
|
||||
) (json.RawMessage, error) {
|
||||
reqBody := map[string]any{protocol: rpcRequest{
|
||||
Method: method,
|
||||
Module: module,
|
||||
Param: param,
|
||||
}}
|
||||
|
||||
respBodyBuf, err := c.doRequest(ctx, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] do request: %w", err)
|
||||
}
|
||||
|
||||
// check rpc response status
|
||||
respStatus := rpcResponse{}
|
||||
if err := json.Unmarshal(respBodyBuf, &respStatus); err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err)
|
||||
}
|
||||
if respStatus.Code != 0 {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] rpc error: %d", respStatus.Code)
|
||||
}
|
||||
|
||||
// parse response data
|
||||
var respBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(respBodyBuf, &respBody); err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
subRespBuf, ok := respBody[protocol]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response not found")
|
||||
}
|
||||
|
||||
subResp := rpcSubResponse{}
|
||||
if err := json.Unmarshal(subRespBuf, &subResp); err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] unmarshal sub-response: %w", err)
|
||||
}
|
||||
|
||||
if subResp.Code != 0 {
|
||||
return nil, fmt.Errorf("qqMusicClient[rpcCall] sub-response error: %d", subResp.Code)
|
||||
}
|
||||
|
||||
return subResp.Data, nil
|
||||
}
|
||||
|
||||
func (c *QQMusic) downloadFile(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qmc[downloadFile] init request: %w", err)
|
||||
}
|
||||
|
||||
//req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") // jpeg is preferred to embed in audio
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.5;q=0.4")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.47.134 Safari/537.36 QBCore/3.53.47.400 QQBrowser/9.0.2524.400")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qmc[downloadFile] send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("qmc[downloadFile] unexpected http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusic {
|
||||
return &QQMusic{
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
21
algo/qmc/client/cover.go
Normal file
21
algo/qmc/client/cover.go
Normal file
@ -0,0 +1,21 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (c *QQMusic) AlbumCoverByID(ctx context.Context, albumID int) ([]byte, error) {
|
||||
u := fmt.Sprintf("https://imgcache.qq.com/music/photo/album/%s/albumpic_%s_0.jpg",
|
||||
strconv.Itoa(albumID%100),
|
||||
strconv.Itoa(albumID),
|
||||
)
|
||||
return c.downloadFile(ctx, u)
|
||||
}
|
||||
|
||||
func (c *QQMusic) AlbumCoverByMediaID(ctx context.Context, mediaID string) ([]byte, error) {
|
||||
// original: https://y.gtimg.cn/music/photo_new/T002M000%s.jpg
|
||||
u := fmt.Sprintf("https://y.gtimg.cn/music/photo_new/T002R500x500M000%s.jpg", mediaID)
|
||||
return c.downloadFile(ctx, u)
|
||||
}
|
178
algo/qmc/client/track.go
Normal file
178
algo/qmc/client/track.go
Normal file
@ -0,0 +1,178 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type getTrackInfoParams struct {
|
||||
Ctx int `json:"ctx"`
|
||||
Ids []int `json:"ids"`
|
||||
Types []int `json:"types"`
|
||||
}
|
||||
|
||||
type getTrackInfoResponse struct {
|
||||
Tracks []*TrackInfo `json:"tracks"`
|
||||
}
|
||||
|
||||
func (c *QQMusic) GetTracksInfo(ctx context.Context, songIDs []int) ([]*TrackInfo, error) {
|
||||
resp, err := c.rpcCall(ctx,
|
||||
"Protocol_UpdateSongInfo",
|
||||
"CgiGetTrackInfo",
|
||||
"music.trackInfo.UniformRuleCtrl",
|
||||
&getTrackInfoParams{Ctx: 0, Ids: songIDs, Types: []int{0}},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] rpc call: %w", err)
|
||||
}
|
||||
respData := getTrackInfoResponse{}
|
||||
if err := json.Unmarshal(resp, &respData); err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return respData.Tracks, nil
|
||||
}
|
||||
|
||||
func (c *QQMusic) GetTrackInfo(ctx context.Context, songID int) (*TrackInfo, error) {
|
||||
tracks, err := c.GetTracksInfo(ctx, []int{songID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] get tracks info: %w", err)
|
||||
}
|
||||
|
||||
if len(tracks) == 0 {
|
||||
return nil, fmt.Errorf("qqMusicClient[GetTrackInfo] track not found")
|
||||
}
|
||||
|
||||
return tracks[0], nil
|
||||
}
|
||||
|
||||
type TrackSinger struct {
|
||||
Id int `json:"id"`
|
||||
Mid string `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Type int `json:"type"`
|
||||
Uin int `json:"uin"`
|
||||
Pmid string `json:"pmid"`
|
||||
}
|
||||
type TrackAlbum struct {
|
||||
Id int `json:"id"`
|
||||
Mid string `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Pmid string `json:"pmid"`
|
||||
}
|
||||
type TrackInfo struct {
|
||||
Id int `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Mid string `json:"mid"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Singer []TrackSinger `json:"singer"`
|
||||
Album TrackAlbum `json:"album"`
|
||||
Mv struct {
|
||||
Id int `json:"id"`
|
||||
Vid string `json:"vid"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Vt int `json:"vt"`
|
||||
} `json:"mv"`
|
||||
Interval int `json:"interval"`
|
||||
Isonly int `json:"isonly"`
|
||||
Language int `json:"language"`
|
||||
Genre int `json:"genre"`
|
||||
IndexCd int `json:"index_cd"`
|
||||
IndexAlbum int `json:"index_album"`
|
||||
TimePublic string `json:"time_public"`
|
||||
Status int `json:"status"`
|
||||
Fnote int `json:"fnote"`
|
||||
File struct {
|
||||
MediaMid string `json:"media_mid"`
|
||||
Size24Aac int `json:"size_24aac"`
|
||||
Size48Aac int `json:"size_48aac"`
|
||||
Size96Aac int `json:"size_96aac"`
|
||||
Size192Ogg int `json:"size_192ogg"`
|
||||
Size192Aac int `json:"size_192aac"`
|
||||
Size128Mp3 int `json:"size_128mp3"`
|
||||
Size320Mp3 int `json:"size_320mp3"`
|
||||
SizeApe int `json:"size_ape"`
|
||||
SizeFlac int `json:"size_flac"`
|
||||
SizeDts int `json:"size_dts"`
|
||||
SizeTry int `json:"size_try"`
|
||||
TryBegin int `json:"try_begin"`
|
||||
TryEnd int `json:"try_end"`
|
||||
Url string `json:"url"`
|
||||
SizeHires int `json:"size_hires"`
|
||||
HiresSample int `json:"hires_sample"`
|
||||
HiresBitdepth int `json:"hires_bitdepth"`
|
||||
B30S int `json:"b_30s"`
|
||||
E30S int `json:"e_30s"`
|
||||
Size96Ogg int `json:"size_96ogg"`
|
||||
Size360Ra []interface{} `json:"size_360ra"`
|
||||
SizeDolby int `json:"size_dolby"`
|
||||
SizeNew []interface{} `json:"size_new"`
|
||||
} `json:"file"`
|
||||
Pay struct {
|
||||
PayMonth int `json:"pay_month"`
|
||||
PriceTrack int `json:"price_track"`
|
||||
PriceAlbum int `json:"price_album"`
|
||||
PayPlay int `json:"pay_play"`
|
||||
PayDown int `json:"pay_down"`
|
||||
PayStatus int `json:"pay_status"`
|
||||
TimeFree int `json:"time_free"`
|
||||
} `json:"pay"`
|
||||
Action struct {
|
||||
Switch int `json:"switch"`
|
||||
Msgid int `json:"msgid"`
|
||||
Alert int `json:"alert"`
|
||||
Icons int `json:"icons"`
|
||||
Msgshare int `json:"msgshare"`
|
||||
Msgfav int `json:"msgfav"`
|
||||
Msgdown int `json:"msgdown"`
|
||||
Msgpay int `json:"msgpay"`
|
||||
Switch2 int `json:"switch2"`
|
||||
Icon2 int `json:"icon2"`
|
||||
} `json:"action"`
|
||||
Ksong struct {
|
||||
Id int `json:"id"`
|
||||
Mid string `json:"mid"`
|
||||
} `json:"ksong"`
|
||||
Volume struct {
|
||||
Gain float64 `json:"gain"`
|
||||
Peak float64 `json:"peak"`
|
||||
Lra float64 `json:"lra"`
|
||||
} `json:"volume"`
|
||||
Label string `json:"label"`
|
||||
Url string `json:"url"`
|
||||
Ppurl string `json:"ppurl"`
|
||||
Bpm int `json:"bpm"`
|
||||
Version int `json:"version"`
|
||||
Trace string `json:"trace"`
|
||||
DataType int `json:"data_type"`
|
||||
ModifyStamp int `json:"modify_stamp"`
|
||||
Aid int `json:"aid"`
|
||||
Tid int `json:"tid"`
|
||||
Ov int `json:"ov"`
|
||||
Sa int `json:"sa"`
|
||||
Es string `json:"es"`
|
||||
Vs []string `json:"vs"`
|
||||
}
|
||||
|
||||
func (t *TrackInfo) GetArtists() []string {
|
||||
return lo.Map(t.Singer, func(v TrackSinger, i int) string {
|
||||
return v.Name
|
||||
})
|
||||
}
|
||||
|
||||
func (t *TrackInfo) GetTitle() string {
|
||||
return t.Title
|
||||
}
|
||||
|
||||
func (t *TrackInfo) GetAlbum() string {
|
||||
return t.Album.Name
|
||||
}
|
@ -27,9 +27,17 @@ type Decoder struct {
|
||||
decodedKey []byte // decodedKey is the decoded key for cipher
|
||||
cipher common.StreamDecoder
|
||||
|
||||
rawMetaExtra1 int
|
||||
songID int
|
||||
rawMetaExtra2 int
|
||||
|
||||
albumID int
|
||||
albumMediaID string
|
||||
|
||||
// cache
|
||||
meta common.AudioMeta
|
||||
cover []byte
|
||||
|
||||
// provider
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@ -199,7 +207,7 @@ func (d *Decoder) readRawMetaQTag() error {
|
||||
return err
|
||||
}
|
||||
|
||||
d.rawMetaExtra1, err = strconv.Atoi(items[1])
|
||||
d.songID, err = strconv.Atoi(items[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
65
algo/qmc/qmc_meta.go
Normal file
65
algo/qmc/qmc_meta.go
Normal file
@ -0,0 +1,65 @@
|
||||
package qmc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"unlock-music.dev/cli/algo/common"
|
||||
"unlock-music.dev/cli/algo/qmc/client"
|
||||
)
|
||||
|
||||
func (d *Decoder) GetAudioMeta(ctx context.Context) (common.AudioMeta, error) {
|
||||
if d.meta != nil {
|
||||
return d.meta, nil
|
||||
}
|
||||
|
||||
if d.songID != 0 {
|
||||
return d.meta, d.getMetaBySongID(ctx)
|
||||
}
|
||||
|
||||
return nil, errors.New("qmc[GetAudioMeta] not implemented")
|
||||
}
|
||||
|
||||
func (d *Decoder) getMetaBySongID(ctx context.Context) error {
|
||||
c := client.NewQQMusicClient() // todo: use global client
|
||||
trackInfo, err := c.GetTrackInfo(ctx, d.songID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qmc[GetAudioMeta] get track info: %w", err)
|
||||
}
|
||||
|
||||
d.meta = trackInfo
|
||||
d.albumID = trackInfo.Album.Id
|
||||
if trackInfo.Album.Pmid == "" {
|
||||
d.albumMediaID = trackInfo.Album.Pmid
|
||||
} else {
|
||||
d.albumMediaID = trackInfo.Album.Mid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Decoder) GetCoverImage(ctx context.Context) ([]byte, error) {
|
||||
if d.cover != nil {
|
||||
return d.cover, nil
|
||||
}
|
||||
|
||||
// todo: get meta if possible
|
||||
c := client.NewQQMusicClient() // todo: use global client
|
||||
var err error
|
||||
|
||||
if d.albumMediaID != "" {
|
||||
d.cover, err = c.AlbumCoverByMediaID(ctx, d.albumMediaID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qmc[GetCoverImage] get cover by media id: %w", err)
|
||||
}
|
||||
} else if d.albumID != 0 {
|
||||
d.cover, err = c.AlbumCoverByID(ctx, d.albumID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qmc[GetCoverImage] get cover by id: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("qmc[GetAudioMeta] album (or media) id is empty")
|
||||
}
|
||||
|
||||
return d.cover, nil
|
||||
}
|
@ -209,6 +209,22 @@ func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFu
|
||||
return err
|
||||
}
|
||||
|
||||
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := audioMetaGetter.GetAudioMeta(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("get audio meta failed", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("audio metadata",
|
||||
zap.String("title", meta.GetTitle()),
|
||||
zap.Strings("artists", meta.GetArtists()),
|
||||
zap.String("album", meta.GetAlbum()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if coverGetter, ok := dec.(common.CoverImageGetter); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@ -227,22 +243,6 @@ func tryDecFile(inputFile string, outputDir string, allDec []common.NewDecoderFu
|
||||
}
|
||||
}
|
||||
|
||||
if audioMetaGetter, ok := dec.(common.AudioMetaGetter); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := audioMetaGetter.GetAudioMeta(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("get audio meta failed", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("audio metadata",
|
||||
zap.String("title", meta.GetTitle()),
|
||||
zap.Strings("artists", meta.GetArtists()),
|
||||
zap.String("album", meta.GetAlbum()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// if source file need to be removed
|
||||
if removeSource {
|
||||
err := os.RemoveAll(inputFile)
|
||||
|
Loading…
Reference in New Issue
Block a user