Files
lrc-local/qq.go
T

171 lines
4.5 KiB
Go

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
}