package main import ( "bytes" "encoding/base64" "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" musicuEndpoint = "https://u.y.qq.com/cgi-bin/musicu.fcg" legacyLyricsEndpoint = "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"` Roma string `json:"roma"` Code int `json:"code"` } type qqAPI interface { Search(string, int) ([]song, error) Lyrics(string) (lyricResult, error) } type qqClient struct { httpClient *http.Client searchEndpoint string musicuEndpoint 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, musicuEndpoint: musicuEndpoint, lyricsEndpoint: legacyLyricsEndpoint, } } 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) { result, err := c.musicuLyrics(mid) if err == nil { return result, nil } return c.legacyLyrics(mid) } func (c *qqClient) musicuLyrics(mid string) (lyricResult, error) { payload := map[string]any{ "comm": map[string]any{"ct": 24, "cv": 0, "format": "json", "platform": "yqq.json"}, "req_1": map[string]any{ "module": "music.musichallSong.PlayLyricInfo", "method": "GetPlayLyricInfo", "param": map[string]any{ "songMID": mid, "songID": 0, "crypt": 0, "qrc": 0, "trans": 1, "roma": 1, }, }, } var response struct { Code int `json:"code"` Request struct { Code int `json:"code"` Data struct { Lyric string `json:"lyric"` Trans string `json:"trans"` Roma string `json:"roma"` } `json:"data"` } `json:"req_1"` } if err := c.postJSON(c.musicuEndpoint, payload, &response); err != nil { return lyricResult{}, err } if response.Code != 0 || response.Request.Code != 0 { return lyricResult{}, errors.New("QQ Music lyrics request failed") } lyric, err := decodeLyricBase64(response.Request.Data.Lyric) if err != nil || strings.TrimSpace(lyric) == "" { return lyricResult{}, errors.New("lyrics are not available for this track") } trans, _ := decodeLyricBase64(response.Request.Data.Trans) roma := "" if response.Request.Data.Roma != "" { roma, _ = decryptQRCLyric(response.Request.Data.Roma) } return lyricResult{Lyric: lyric, Trans: trans, Roma: roma, Code: 0}, nil } func (c *qqClient) legacyLyrics(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 decodeLyricBase64(value string) (string, error) { if value == "" { return "", nil } decoded, err := base64.StdEncoding.DecodeString(value) if err != nil { return "", errors.New("QQ Music returned invalid lyric encoding") } if len(decoded) > maxUpstreamBytes { return "", errors.New("decoded lyrics exceeded the safety limit") } return string(decoded), nil } func (c *qqClient) postJSON(endpoint string, payload, destination any) error { body, err := json.Marshal(payload) if err != nil { return err } request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return err } request.Header.Set("Content-Type", "application/json") return c.doJSON(request, destination) } 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 } return c.doJSON(request, destination) } func (c *qqClient) doJSON(request *http.Request, destination any) error { 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 }