From 9494a535a922461d199d542e1d6b5649e2c6256a Mon Sep 17 00:00:00 2001 From: Unlock Music Dev Date: Tue, 22 Nov 2022 11:08:35 +0800 Subject: [PATCH] feat(qmc): support audio meta getter --- algo/qmc/client/base.go | 151 +++++++++++++++++++++++++++++++++ algo/qmc/client/cover.go | 21 +++++ algo/qmc/client/track.go | 178 +++++++++++++++++++++++++++++++++++++++ algo/qmc/qmc.go | 12 ++- algo/qmc/qmc_meta.go | 65 ++++++++++++++ cmd/um/main.go | 32 +++---- 6 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 algo/qmc/client/base.go create mode 100644 algo/qmc/client/cover.go create mode 100644 algo/qmc/client/track.go create mode 100644 algo/qmc/qmc_meta.go diff --git a/algo/qmc/client/base.go b/algo/qmc/client/base.go new file mode 100644 index 0000000..62f0b53 --- /dev/null +++ b/algo/qmc/client/base.go @@ -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, + }, + } +} diff --git a/algo/qmc/client/cover.go b/algo/qmc/client/cover.go new file mode 100644 index 0000000..a94f106 --- /dev/null +++ b/algo/qmc/client/cover.go @@ -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) +} diff --git a/algo/qmc/client/track.go b/algo/qmc/client/track.go new file mode 100644 index 0000000..0d2b55b --- /dev/null +++ b/algo/qmc/client/track.go @@ -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 +} diff --git a/algo/qmc/qmc.go b/algo/qmc/qmc.go index 3347882..7e8597f 100644 --- a/algo/qmc/qmc.go +++ b/algo/qmc/qmc.go @@ -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 } diff --git a/algo/qmc/qmc_meta.go b/algo/qmc/qmc_meta.go new file mode 100644 index 0000000..23493be --- /dev/null +++ b/algo/qmc/qmc_meta.go @@ -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 +} diff --git a/cmd/um/main.go b/cmd/um/main.go index 51088c7..1a2b8ea 100644 --- a/cmd/um/main.go +++ b/cmd/um/main.go @@ -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)