255 lines
7.0 KiB
Go
255 lines
7.0 KiB
Go
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
|
|
}
|