package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) const ( searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp" lyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" maxUpstreamBytes = 4 << 20 ) type song struct { Name string `json:"name"` MID string `json:"mid"` Singer string `json:"singer"` Album string `json:"album"` Interval int `json:"interval"` } type lyricResult struct { Lyric string `json:"lyric"` Trans string `json:"trans"` Code int `json:"code"` } type qqAPI interface { Search(string, int) ([]song, error) Lyrics(string) (lyricResult, error) } type qqClient struct { httpClient *http.Client searchEndpoint string lyricsEndpoint string } func newQQClient() *qqClient { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 8 transport.MaxIdleConnsPerHost = 4 transport.ResponseHeaderTimeout = 10 * time.Second return &qqClient{ httpClient: &http.Client{ Timeout: 15 * time.Second, Transport: transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return errors.New("unexpected redirect from fixed QQ Music endpoint") }, }, searchEndpoint: searchEndpoint, lyricsEndpoint: lyricsEndpoint, } } func (c *qqClient) Search(keyword string, limit int) ([]song, error) { query := url.Values{ "format": {"json"}, "p": {"1"}, "n": {fmt.Sprint(limit)}, "w": {keyword}, "outCharset": {"utf-8"}, } var response struct { Code int `json:"code"` Message string `json:"message"` Data struct { Song struct { List []struct { SongName string `json:"songname"` SongMID string `json:"songmid"` AlbumName string `json:"albumname"` Interval int `json:"interval"` Singer []struct { Name string `json:"name"` } `json:"singer"` } `json:"list"` } `json:"song"` } `json:"data"` } if err := c.getJSON(c.searchEndpoint, query, &response); err != nil { return nil, err } if response.Code != 0 { return nil, fmt.Errorf("QQ Music search failed: %s", response.Message) } result := make([]song, 0, len(response.Data.Song.List)) for _, item := range response.Data.Song.List { if item.SongMID == "" { continue } names := make([]string, 0, len(item.Singer)) for _, singer := range item.Singer { if singer.Name != "" { names = append(names, singer.Name) } } result = append(result, song{ Name: item.SongName, MID: item.SongMID, Singer: strings.Join(names, " / "), Album: item.AlbumName, Interval: item.Interval, }) } return result, nil } func (c *qqClient) Lyrics(mid string) (lyricResult, error) { query := url.Values{ "format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"}, } var result lyricResult if err := c.getJSON(c.lyricsEndpoint, query, &result); err != nil { return lyricResult{}, err } if result.Code != 0 || result.Lyric == "" { return lyricResult{}, errors.New("lyrics are not available for this track") } return result, nil } func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error { request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil) if err != nil { return err } request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36") request.Header.Set("Referer", "https://y.qq.com/") request.Header.Set("Origin", "https://y.qq.com") request.Header.Set("Accept", "application/json,text/plain,*/*") response, err := c.httpClient.Do(request) if err != nil { return fmt.Errorf("QQ Music request failed: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("QQ Music returned HTTP %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamBytes+1)) if err != nil { return err } if len(body) > maxUpstreamBytes { return errors.New("QQ Music response exceeded the safety limit") } body = unwrapJSONP(body) if err := json.Unmarshal(body, destination); err != nil { return errors.New("QQ Music returned an invalid response") } return nil } func unwrapJSONP(body []byte) []byte { body = bytes.TrimSpace(body) if len(body) == 0 || body[0] == '{' || body[0] == '[' { return body } left := bytes.IndexByte(body, '(') right := bytes.LastIndexByte(body, ')') if left >= 0 && right > left { return bytes.TrimSpace(body[left+1 : right]) } return body }