feat(qmc): support audio meta getter
parent
138adbf846
commit
9494a535a9
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue