feat: add translated and word-by-word lyrics for QQ and NetEase
This commit is contained in:
1 parent
8640da55e1
commit
bec757c7a5
26 files changed
+938
-88
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -34,9 +35,10 @@ type Client struct {
|
||||
cookie cookieCache
|
||||
cache *expirable.LRU[string, any]
|
||||
observe func(source, op string, elapsed time.Duration, err error)
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(httpClient *http.Client, cookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client {
|
||||
func New(httpClient *http.Client, cookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error), logger *slog.Logger) *Client {
|
||||
api := *httpClient
|
||||
api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
@@ -45,9 +47,17 @@ func New(httpClient *http.Client, cookie func(context.Context) (string, error),
|
||||
cookie: cookieCache{load: cookie, store: saveCookie},
|
||||
cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL),
|
||||
observe: observe,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// interfaceError 表示接口本身失败:网络错误、HTTP 非 200、上游返回错误码。
|
||||
// 这类失败重试可能成功;响应解析失败不属于此类,重试结果不会变。
|
||||
type interfaceError struct{ err error }
|
||||
|
||||
func (e *interfaceError) Error() string { return e.err.Error() }
|
||||
func (e *interfaceError) Unwrap() error { return e.err }
|
||||
|
||||
type cookieCache struct {
|
||||
load func(context.Context) (string, error)
|
||||
store func(context.Context, string) error
|
||||
@@ -114,7 +124,7 @@ func (c *Client) call(ctx context.Context, op, endpoint string, form url.Values,
|
||||
start := time.Now()
|
||||
err = c.post(ctx, endpoint, form, cookie, out)
|
||||
if err == nil && out.status() != http.StatusOK {
|
||||
err = fmt.Errorf("网易云的接口 %s 返回错误码 %d", op, out.status())
|
||||
err = &interfaceError{fmt.Errorf("网易云的接口 %s 返回错误码 %d", op, out.status())}
|
||||
}
|
||||
c.observe("netease", op, time.Since(start), err)
|
||||
return err
|
||||
@@ -134,11 +144,11 @@ func (c *Client) post(ctx context.Context, endpoint string, form url.Values, coo
|
||||
}
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return requestError(req.URL.Host, err)
|
||||
return &interfaceError{requestError(req.URL.Host, err)}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode)
|
||||
return &interfaceError{fmt.Errorf("网易云返回 HTTP %d", resp.StatusCode)}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("解析网易云的响应:%w", err)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package netease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 歌词接口失败:就地重试一次仍失败则降级为空歌词,元数据和音频不受影响。
|
||||
func TestResolveDegradesOnLyricInterfaceFailure(t *testing.T) {
|
||||
calls := 0
|
||||
routes := resolveRoutes(t)
|
||||
routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
|
||||
routes["/weapi/song/lyric/v1"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "3387653989")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Lyric != "" || got.LyricWords != "" || got.LyricTrans != "" {
|
||||
t.Errorf("降级后的歌词 = %q %q %q,期望全空", got.Lyric, got.LyricWords, got.LyricTrans)
|
||||
}
|
||||
if got.Meta != cover.Meta {
|
||||
t.Errorf("元数据 = %#v", got.Meta)
|
||||
}
|
||||
if audio := readAudio(t, got); audio != "ID3 音频" {
|
||||
t.Errorf("音频 = %q", audio)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("歌词接口调用了 %d 次,期望失败后重试一次", calls)
|
||||
}
|
||||
obs.check(t, "track_detail", "lyric 失败", "lyric 失败", "play_url")
|
||||
}
|
||||
|
||||
// 第一次失败、重试成功时歌词正常返回。
|
||||
func TestResolveRetriesLyricOnce(t *testing.T) {
|
||||
calls := 0
|
||||
routes := resolveRoutes(t)
|
||||
routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
|
||||
routes["/weapi/song/lyric/v1"] = func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fixture(t, "lyric.json")(w, r)
|
||||
}
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "3387653989")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = got.Audio.Close() }()
|
||||
if got.Lyric == "" || got.LyricWords == "" || got.LyricTrans == "" {
|
||||
t.Errorf("重试成功后的歌词 = %q %q %q", got.Lyric, got.LyricWords, got.LyricTrans)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("歌词接口调用了 %d 次,期望一次失败一次成功", calls)
|
||||
}
|
||||
obs.check(t, "track_detail", "lyric 失败", "lyric", "play_url")
|
||||
}
|
||||
|
||||
// 调用方 ctx 被取消时返回错误,不降级:任务退出时中断,不能把空歌词写进曲目表。
|
||||
func TestResolveDoesNotDegradeWhenCtxCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
routes := resolveRoutes(t)
|
||||
routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
|
||||
routes["/weapi/song/lyric/v1"] = func(w http.ResponseWriter, _ *http.Request) {
|
||||
cancel()
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
c, _ := serve(t, routes)
|
||||
if _, err := c.Resolve(ctx, "3387653989"); err == nil {
|
||||
t.Fatal("ctx 取消时 Resolve 没有返回错误")
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func serveLoginWithStore(t *testing.T, routes map[string]http.HandlerFunc, store
|
||||
}
|
||||
obs := &observed{}
|
||||
load := func(context.Context) (string, error) { return "MUSIC_U=loaded", nil }
|
||||
return New(&http.Client{Transport: redirect{target}}, load, store, obs.record), obs
|
||||
return New(&http.Client{Transport: redirect{target}}, load, store, obs.record, testLogger), obs
|
||||
}
|
||||
|
||||
func TestCreateQRLogin(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,7 +18,7 @@ const (
|
||||
linuxAPI = "https://music.163.com/api/linux/forward"
|
||||
detailAPI = "https://music.163.com/weapi/v3/song/detail"
|
||||
playlistAPI = "https://music.163.com/weapi/v3/playlist/detail"
|
||||
lyricAPI = "https://music.163.com/weapi/song/lyric"
|
||||
lyricAPI = "https://music.163.com/weapi/song/lyric/v1"
|
||||
similarAPI = "https://music.163.com/weapi/discovery/simiSong"
|
||||
weapiURLAPI = "https://music.163.com/weapi/song/enhance/player/url"
|
||||
eapiURLAPI = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
|
||||
@@ -247,7 +249,7 @@ func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
lyric, err := c.lyric(ctx, id)
|
||||
lyric, err := c.lyricOrDegrade(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
@@ -259,28 +261,91 @@ func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil
|
||||
return port.Resolved{Meta: meta, Lyric: lyric.LRC, LyricWords: lyric.Words, LyricTrans: lyric.Trans, Audio: audio}, nil
|
||||
}
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (string, error) {
|
||||
return cached(c, "lyric "+id, func() (string, error) {
|
||||
form, err := weapi(map[string]any{"csrf_token": "", "id": id, "lv": -1, "tv": -1})
|
||||
// 行级 LRC 原文、逐字歌词(YRC 语法)、行级翻译 LRC,后两者可为空。
|
||||
type lyricResult struct {
|
||||
LRC string
|
||||
Words string
|
||||
Trans string
|
||||
}
|
||||
|
||||
// lyricOrDegrade 让歌词问题不阻塞播放:接口失败就地重试一次,仍失败(或解析失败)则降级为无歌词并记 WARN。
|
||||
// 曲目表里有记录的歌不会重新准备,网络抖动后直接降级会让这首歌永久没有歌词,所以先重试。
|
||||
// 调用方的 ctx 被取消或超时不降级:任务退出时中断,不能把空歌词写进曲目表。
|
||||
func (c *Client) lyricOrDegrade(ctx context.Context, id string) (lyricResult, error) {
|
||||
r, err := c.lyric(ctx, id)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return r, err
|
||||
}
|
||||
var iface *interfaceError
|
||||
if errors.As(err, &iface) {
|
||||
r, err = c.lyric(ctx, id)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return r, err
|
||||
}
|
||||
}
|
||||
c.logger.WarnContext(ctx, "歌词获取失败,按无歌词处理", "source", "netease", "track_id", id, "error", err)
|
||||
return lyricResult{}, nil
|
||||
}
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (lyricResult, error) {
|
||||
return cached(c, "lyric "+id, func() (lyricResult, error) {
|
||||
form, err := weapi(map[string]any{"csrf_token": "", "id": id, "lv": -1, "tv": -1, "rv": -1, "yv": -1})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return lyricResult{}, err
|
||||
}
|
||||
var resp struct {
|
||||
base
|
||||
Lrc struct {
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"lrc"`
|
||||
Tlyric struct {
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"tlyric"`
|
||||
Yrc struct {
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"yrc"`
|
||||
Ytlrc struct {
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"ytlrc"`
|
||||
}
|
||||
if err := c.call(ctx, "lyric", lyricAPI, form, &resp); err != nil {
|
||||
return "", err
|
||||
return lyricResult{}, err
|
||||
}
|
||||
return text(resp.Lrc.Lyric), nil
|
||||
out := lyricResult{LRC: text(resp.Lrc.Lyric), Words: text(resp.Yrc.Lyric)}
|
||||
if out.LRC == "" && out.Words != "" {
|
||||
out.LRC = yrcToLRC(out.Words)
|
||||
}
|
||||
if trans := text(resp.Ytlrc.Lyric); trans != "" {
|
||||
out.Trans = trans
|
||||
} else {
|
||||
out.Trans = text(resp.Tlyric.Lyric)
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// 逐字歌词里没有行级原文时,剥掉字时间标记还原出行级 LRC。
|
||||
var (
|
||||
yrcLineRe = regexp.MustCompile(`^\[(\d+),\d+\]`)
|
||||
yrcWordRe = regexp.MustCompile(`\(\d+,\d+,\d+\)`)
|
||||
)
|
||||
|
||||
func yrcToLRC(yrc string) string {
|
||||
var b strings.Builder
|
||||
for _, row := range strings.Split(yrc, "\n") {
|
||||
m := yrcLineRe.FindStringSubmatch(row)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
startMs, _ := strconv.Atoi(m[1])
|
||||
fmt.Fprintf(&b, "[%02d:%05.2f]%s\n", startMs/60000, float64(startMs%60000)/1000, yrcWordRe.ReplaceAllString(row, ""))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *Client) PlayURL(ctx context.Context, id string) (string, error) {
|
||||
eapiForm, err := eapi(eapiURLAPI, map[string]any{
|
||||
"ids": []json.Number{json.Number(id)},
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
)
|
||||
|
||||
// 测试服务器按路径返回 testdata 中录制的网易云响应,Client 的所有请求都转到它。
|
||||
var testLogger = slog.New(slog.DiscardHandler)
|
||||
|
||||
func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,7 +50,7 @@ func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed
|
||||
}
|
||||
obs := &observed{}
|
||||
cookie := func(context.Context) (string, error) { return "MUSIC_U=secret", nil }
|
||||
return New(&http.Client{Transport: redirect{target}}, cookie, func(context.Context, string) error { return nil }, obs.record), obs
|
||||
return New(&http.Client{Transport: redirect{target}}, cookie, func(context.Context, string) error { return nil }, obs.record, testLogger), obs
|
||||
}
|
||||
|
||||
type redirect struct{ target *url.URL }
|
||||
@@ -251,14 +253,14 @@ func TestTrackMeta(t *testing.T) {
|
||||
func TestTextDropsNUL(t *testing.T) {
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{
|
||||
"/weapi/v3/song/detail": body(`{"songs":[{"name":"晴\u0000天","id":1,"ar":[{"name":"周\u0000杰伦"}],"al":{"name":"叶\u0000惠美","picUrl":"https://p1.music.126.net/a\u0000.jpg"},"dt":1000}],"code":200}`),
|
||||
"/weapi/song/lyric": body(`{"lrc":{"lyric":"[00:01.00]故\u0000事"},"code":200}`),
|
||||
"/weapi/song/lyric/v1": body(`{"lrc":{"lyric":"[00:01.00]故\u0000事"},"code":200}`),
|
||||
})
|
||||
want := track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Second}
|
||||
if got, err := c.TrackMeta(t.Context(), "1"); err != nil || got != want {
|
||||
t.Errorf("元数据 = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if got, err := c.lyric(t.Context(), "1"); err != nil || got != "[00:01.00]故事" {
|
||||
t.Errorf("歌词 = %q, %v", got, err)
|
||||
if got, err := c.lyric(t.Context(), "1"); err != nil || got.LRC != "[00:01.00]故事" {
|
||||
t.Errorf("歌词 = %q, %v", got.LRC, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +287,7 @@ const audioPath = "/song.mp3"
|
||||
func resolveRoutes(t *testing.T) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/weapi/v3/song/detail": fixture(t, "song-detail.json"),
|
||||
"/weapi/song/lyric": fixture(t, "lyric.json"),
|
||||
"/weapi/song/lyric/v1": fixture(t, "lyric.json"),
|
||||
audioPath: func(w http.ResponseWriter, r *http.Request) {
|
||||
// 音频来自 CDN,网易云的 Cookie 不能发给它。
|
||||
if got := r.Header.Get("Cookie"); got != "" {
|
||||
@@ -336,7 +338,9 @@ func TestResolve(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Meta != cover.Meta || got.Lyric != "[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n" {
|
||||
wantWords := "[16350,3810](16350,600,0)故(16950,600,0)事(17550,600,0)的(18150,600,0)小(18750,600,0)黄(19350,600,0)花(19950,210,0)\n[20160,4000](20160,500,0)从(20660,500,0)出(21160,500,0)生(21660,500,0)那(22160,500,0)年(22660,500,0)就(23160,500,0)飘(23660,500,0)着(24160,500,0)\n"
|
||||
wantTrans := "[00:16.35]Story of the flower\n[00:20.16]Floating since birth\n"
|
||||
if got.Meta != cover.Meta || got.Lyric != "[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n" || got.LyricWords != wantWords || got.LyricTrans != wantTrans {
|
||||
t.Errorf("解析结果 = %#v", got)
|
||||
}
|
||||
if audio := readAudio(t, got); audio != "ID3 音频" {
|
||||
@@ -576,7 +580,7 @@ func TestCookieIsNotSentOverPlainHTTPAfterRedirect(t *testing.T) {
|
||||
}
|
||||
obs := &observed{}
|
||||
cookie := func(context.Context) (string, error) { return "MUSIC_U=secret", nil }
|
||||
c := New(&http.Client{Transport: split{secure: secureURL, plain: plainURL, transport: secure.Client().Transport}}, cookie, func(context.Context, string) error { return nil }, obs.record)
|
||||
c := New(&http.Client{Transport: split{secure: secureURL, plain: plainURL, transport: secure.Client().Transport}}, cookie, func(context.Context, string) error { return nil }, obs.record, testLogger)
|
||||
|
||||
if _, err := c.TrackMeta(t.Context(), "3387653989"); err == nil || !strings.Contains(err.Error(), "HTTP 307") {
|
||||
t.Errorf("接口重定向时的错误 = %v,期望 HTTP 307", err)
|
||||
@@ -618,6 +622,7 @@ func TestDownloadErrorDoesNotExposeSignedURL(t *testing.T) {
|
||||
func(context.Context) (string, error) { return "", nil },
|
||||
func(context.Context, string) error { return nil },
|
||||
func(string, string, time.Duration, error) {},
|
||||
testLogger,
|
||||
)
|
||||
_, err := c.download(t.Context(), signedURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "网络中断") {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"sgc":false,"sfy":false,"qfy":false,"lrc":{"version":2,"lyric":"[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n"},"code":200}
|
||||
{"sgc":false,"sfy":false,"qfy":false,"lrc":{"version":2,"lyric":"[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n"},"yrc":{"version":1,"lyric":"[16350,3810](16350,600,0)故(16950,600,0)事(17550,600,0)的(18150,600,0)小(18750,600,0)黄(19350,600,0)花(19950,210,0)\n[20160,4000](20160,500,0)从(20660,500,0)出(21160,500,0)生(21660,500,0)那(22160,500,0)年(22660,500,0)就(23160,500,0)飘(23660,500,0)着(24160,500,0)\n"},"ytlrc":{"version":1,"lyric":"[00:16.35]Story of the flower\n[00:20.16]Floating since birth\n"},"code":200}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func accountTestClient(cookie string, loadErr error) *Client {
|
||||
load := func(context.Context) (string, error) { return cookie, loadErr }
|
||||
return New(&http.Client{}, load, nil, (&observed{}).record)
|
||||
return New(&http.Client{}, load, nil, (&observed{}).record, testLogger)
|
||||
}
|
||||
|
||||
func TestCurrentAccount(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -32,9 +33,10 @@ type Client struct {
|
||||
cookie cookieCache
|
||||
cache *expirable.LRU[string, any]
|
||||
observe func(source, op string, elapsed time.Duration, err error)
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(httpClient *http.Client, loadCookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error)) *Client {
|
||||
func New(httpClient *http.Client, loadCookie func(context.Context) (string, error), saveCookie func(context.Context, string) error, observe func(source, op string, elapsed time.Duration, err error), logger *slog.Logger) *Client {
|
||||
api := *httpClient
|
||||
api.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
@@ -43,9 +45,17 @@ func New(httpClient *http.Client, loadCookie func(context.Context) (string, erro
|
||||
cookie: cookieCache{load: loadCookie, store: saveCookie},
|
||||
cache: expirable.NewLRU[string, any](cacheSize, nil, cacheTTL),
|
||||
observe: observe,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// interfaceError 表示接口本身失败:网络错误、HTTP 非 200、上游返回错误码。
|
||||
// 这类失败重试可能成功;响应解析失败、解密失败不属于此类,重试结果不会变。
|
||||
type interfaceError struct{ err error }
|
||||
|
||||
func (e *interfaceError) Error() string { return e.err.Error() }
|
||||
func (e *interfaceError) Unwrap() error { return e.err }
|
||||
|
||||
type cookieCache struct {
|
||||
load func(context.Context) (string, error)
|
||||
store func(context.Context, string) error
|
||||
@@ -138,11 +148,11 @@ func (c *Client) fetch(ctx context.Context, target string, header http.Header, o
|
||||
chinaip.Set(req.Header)
|
||||
resp, err := c.api.Do(req)
|
||||
if err != nil {
|
||||
return requestError(req.URL.Host, err)
|
||||
return &interfaceError{requestError(req.URL.Host, err)}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s 返回 HTTP %d", req.URL.Host, resp.StatusCode)
|
||||
return &interfaceError{fmt.Errorf("%s 返回 HTTP %d", req.URL.Host, resp.StatusCode)}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("解析 %s 的响应:%w", req.URL.Host, err)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var okPlayResponse = `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"F000004Fs2FP1EvZYc004Fs2FP1EvZYc.flac","purl":"` + audioPath[1:] + `"}]}}}`
|
||||
|
||||
func readQQAudio(t *testing.T, audio io.ReadCloser) string {
|
||||
t.Helper()
|
||||
defer func() { _ = audio.Close() }()
|
||||
b, err := io.ReadAll(audio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 歌词接口失败:就地重试一次仍失败则降级为空歌词,元数据和音频不受影响。
|
||||
func TestResolveDegradesOnLyricInterfaceFailure(t *testing.T) {
|
||||
calls := 0
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = musicuLyric(t, okPlayResponse, func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
})
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Lyric != "" || got.LyricWords != "" || got.LyricTrans != "" {
|
||||
t.Errorf("降级后的歌词 = %q %q %q,期望全空", got.Lyric, got.LyricWords, got.LyricTrans)
|
||||
}
|
||||
if got.Meta != live.Meta {
|
||||
t.Errorf("元数据 = %#v", got.Meta)
|
||||
}
|
||||
if audio := readQQAudio(t, got.Audio); audio != "ID3 音频" {
|
||||
t.Errorf("音频 = %q", audio)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("歌词接口调用了 %d 次,期望失败后重试一次", calls)
|
||||
}
|
||||
obs.check(t, "qq track_detail", "qq lyric 失败", "qq lyric 失败", "qq play_url")
|
||||
}
|
||||
|
||||
// 第一次失败、重试成功时歌词正常返回。
|
||||
func TestResolveRetriesLyricOnce(t *testing.T) {
|
||||
calls := 0
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = musicuLyric(t, okPlayResponse, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fixture(t, "lyric.json")(w, r)
|
||||
})
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = got.Audio.Close() }()
|
||||
if got.Lyric != "[ti:逐字测试]\n[ar:歌手]\n[00:01.00]你好世\n[00:03.00]再见\n" || got.LyricWords == "" || got.LyricTrans == "" {
|
||||
t.Errorf("重试成功后的歌词 = %q %q %q", got.Lyric, got.LyricWords, got.LyricTrans)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("歌词接口调用了 %d 次,期望一次失败一次成功", calls)
|
||||
}
|
||||
obs.check(t, "qq track_detail", "qq lyric 失败", "qq lyric", "qq play_url")
|
||||
}
|
||||
|
||||
// 解密失败重试结果也不会变,直接降级,不重试。
|
||||
func TestResolveDegradesOnLyricDecryptFailure(t *testing.T) {
|
||||
calls := 0
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = musicuLyric(t, okPlayResponse, func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
body(`{"code":0,"request":{"code":0,"data":{"lyric":"deadbeefzz","trans":""}}}`)(w, nil)
|
||||
})
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = got.Audio.Close() }()
|
||||
if got.Lyric != "" || got.LyricWords != "" || got.LyricTrans != "" {
|
||||
t.Errorf("解密失败降级后的歌词 = %q %q %q,期望全空", got.Lyric, got.LyricWords, got.LyricTrans)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("解密失败后歌词接口调用了 %d 次,期望不重试", calls)
|
||||
}
|
||||
// 解密在接口调用成功之后,观测上歌词请求本身没有失败,降级由 WARN 日志体现。
|
||||
obs.check(t, "qq track_detail", "qq lyric", "qq play_url")
|
||||
}
|
||||
|
||||
// 调用方 ctx 被取消时返回错误,不降级:任务退出时中断,不能把空歌词写进曲目表。
|
||||
func TestResolveDoesNotDegradeWhenCtxCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = musicuLyric(t, okPlayResponse, func(w http.ResponseWriter, _ *http.Request) {
|
||||
cancel()
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
})
|
||||
c, _ := serve(t, routes)
|
||||
if _, err := c.Resolve(ctx, "004Fs2FP1EvZYc"); err == nil {
|
||||
t.Fatal("ctx 取消时 Resolve 没有返回错误")
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func serveQQQRLogin(t *testing.T, routes map[string]http.HandlerFunc, store func
|
||||
func(context.Context) (string, error) { return testCookie, nil },
|
||||
store,
|
||||
obs.record,
|
||||
testLogger,
|
||||
)
|
||||
}, obs
|
||||
}
|
||||
@@ -1079,6 +1080,7 @@ func TestWeChatMusicLoginFallsBackAcrossEndpoints(t *testing.T) {
|
||||
func(context.Context) (string, error) { return "", nil },
|
||||
func(context.Context, string) error { return nil },
|
||||
func(string, string, time.Duration, error) {},
|
||||
testLogger,
|
||||
)
|
||||
cookie, err := c.completeWeChatLogin(t.Context(), c.api, testWeChatCode)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
playURLAPI = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
musicuAPI = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
playURLCDN = "https://ws.stream.qqmusic.qq.com/"
|
||||
)
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *Client) PlayURL(ctx context.Context, songMID string) (string, error) {
|
||||
}
|
||||
var resp playURLResponse
|
||||
query := url.Values{"data": {string(payload)}}
|
||||
if err := c.get(ctx, "qq", "play_url", playURLAPI, query, desktopHeader, &resp); err != nil {
|
||||
if err := c.get(ctx, "qq", "play_url", musicuAPI, query, desktopHeader, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, filename := range filenames {
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestResolveDoesNotFallbackForInvalidPlayURLResponses(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = body(tt.response)
|
||||
routes["/cgi-bin/musicu.fcg"] = playRequest(t, body(tt.response))
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
|
||||
if got.Audio != nil {
|
||||
@@ -206,6 +206,7 @@ func TestDownloadErrorDoesNotExposeSignedURL(t *testing.T) {
|
||||
func(context.Context) (string, error) { return "", nil },
|
||||
func(context.Context, string) error { return nil },
|
||||
func(string, string, time.Duration, error) {},
|
||||
testLogger,
|
||||
)
|
||||
_, err := c.download(t.Context(), signedURL)
|
||||
if err == nil || !strings.Contains(err.Error(), "网络中断") {
|
||||
|
||||
+127
-23
@@ -2,15 +2,17 @@ package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/qq/qrc"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
@@ -19,7 +21,6 @@ const (
|
||||
searchAPI = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp"
|
||||
searchPlaylistAPI = "https://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist"
|
||||
detailAPI = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg"
|
||||
lyricAPI = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -149,7 +150,7 @@ func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
lyric, err := c.lyric(ctx, id)
|
||||
lyric, err := c.lyricOrDegrade(ctx, id)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
@@ -161,37 +162,140 @@ func (c *Client) Resolve(ctx context.Context, id string) (port.Resolved, error)
|
||||
if err != nil {
|
||||
return port.Resolved{}, err
|
||||
}
|
||||
return port.Resolved{Meta: meta, Lyric: lyric, Audio: audio}, nil
|
||||
return port.Resolved{Meta: meta, Lyric: lyric.LRC, LyricWords: lyric.Words, LyricTrans: lyric.Trans, Audio: audio}, nil
|
||||
}
|
||||
|
||||
type lyricResponse struct {
|
||||
Code int `json:"code"`
|
||||
Lyric string `json:"lyric"`
|
||||
type lyricResult struct {
|
||||
// 行级 LRC 原文、逐字歌词(YRC 语法)、行级翻译 LRC,后两者可为空。
|
||||
LRC string
|
||||
Words string
|
||||
Trans string
|
||||
}
|
||||
|
||||
func (lyricResponse) check() error { return nil }
|
||||
type playLyricResponse struct {
|
||||
Code *int `json:"code"`
|
||||
Request struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
Lyric string `json:"lyric"`
|
||||
Trans string `json:"trans"`
|
||||
} `json:"data"`
|
||||
} `json:"request"`
|
||||
}
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (string, error) {
|
||||
return cached(c, "lyric "+id, func() (string, error) {
|
||||
var resp lyricResponse
|
||||
query := url.Values{
|
||||
"songmid": {id}, "loginUin": {"0"}, "hostUin": {"0"}, "format": {"json"}, "inCharset": {"utf8"},
|
||||
"outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"y.qq.json"}, "needNewCode": {"0"},
|
||||
func (r playLyricResponse) check() error {
|
||||
if r.Code == nil || r.Request.Code == nil {
|
||||
return errors.New("QQ 音乐歌词响应的 code 或 request.code 缺失或为 null")
|
||||
}
|
||||
if *r.Code != 0 {
|
||||
return &interfaceError{fmt.Errorf("QQ 音乐返回错误码 %d", *r.Code)}
|
||||
}
|
||||
if *r.Request.Code != 0 {
|
||||
return &interfaceError{fmt.Errorf("QQ 音乐歌词请求返回错误码 %d", *r.Request.Code)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lyricOrDegrade 让歌词问题不阻塞播放:接口失败就地重试一次,仍失败(或解析、解密失败)则降级为无歌词并记 WARN。
|
||||
// 曲目表里有记录的歌不会重新准备,网络抖动后直接降级会让这首歌永久没有歌词,所以先重试。
|
||||
// 调用方的 ctx 被取消或超时不降级:任务退出时中断,不能把空歌词写进曲目表。
|
||||
func (c *Client) lyricOrDegrade(ctx context.Context, id string) (lyricResult, error) {
|
||||
r, err := c.lyric(ctx, id)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return r, err
|
||||
}
|
||||
var iface *interfaceError
|
||||
if errors.As(err, &iface) {
|
||||
r, err = c.lyric(ctx, id)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return r, err
|
||||
}
|
||||
if err := c.get(ctx, "qq", "lyric", lyricAPI, query, lyricHeader, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Code != 0 || resp.Lyric == "" {
|
||||
return "", nil
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(resp.Lyric)
|
||||
}
|
||||
c.logger.WarnContext(ctx, "歌词获取失败,按无歌词处理", "source", "qq", "track_id", id, "error", err)
|
||||
return lyricResult{}, nil
|
||||
}
|
||||
|
||||
func (c *Client) lyric(ctx context.Context, id string) (lyricResult, error) {
|
||||
return cached(c, "lyric "+id, func() (lyricResult, error) {
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"comm": map[string]any{"ct": 11, "cv": "1003006", "platform": "yqq.json", "needNewCode": 0},
|
||||
"request": map[string]any{
|
||||
"module": "music.musichallSong.PlayLyricInfo",
|
||||
"method": "GetPlayLyricInfo",
|
||||
"param": map[string]any{
|
||||
"songMID": id, "songID": 0, "songName": "", "singerName": "", "albumName": "",
|
||||
"crypt": 1, "ct": 19, "cv": 2111, "interval": 0, "type": 0,
|
||||
"qrc": 1, "qrc_t": 0, "trans": 1, "trans_t": 0, "roma": 0, "roma_t": 0, "lrc_t": 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码 QQ 音乐的歌词:%w", err)
|
||||
return lyricResult{}, err
|
||||
}
|
||||
return text(string(b)), nil
|
||||
var resp playLyricResponse
|
||||
query := url.Values{"data": {string(payload)}}
|
||||
if err := c.get(ctx, "qq", "lyric", musicuAPI, query, lyricHeader, &resp); err != nil {
|
||||
return lyricResult{}, err
|
||||
}
|
||||
return parsePlayLyric(resp.Request.Data.Lyric, resp.Request.Data.Trans)
|
||||
})
|
||||
}
|
||||
|
||||
func parsePlayLyric(lyricHex, transHex string) (lyricResult, error) {
|
||||
var out lyricResult
|
||||
if lyricHex != "" {
|
||||
plain, err := qrc.DecryptHex(lyricHex)
|
||||
if err != nil {
|
||||
return lyricResult{}, fmt.Errorf("解密 QQ 音乐的歌词:%w", err)
|
||||
}
|
||||
tags, lines := qrc.Parse(plain)
|
||||
if len(lines) == 0 {
|
||||
// 没有逐字数据时 LyricContent 里直接是行级 LRC。
|
||||
out.LRC = text(plain)
|
||||
} else {
|
||||
out.LRC = text(qrc.RenderLRC(tags, lines))
|
||||
if hasWordTimings(lines) {
|
||||
out.Words = qrc.RenderYRC(lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
if transHex != "" {
|
||||
// 翻译解密后是行级 LRC;翻译是增强信息,解密失败只丢翻译。
|
||||
if plain, err := qrc.DecryptHex(transHex); err == nil {
|
||||
out.Trans = cleanTrans(plain)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var transLineRe = regexp.MustCompile(`^\[\d+:\d+(?:\.\d+)?\](.*)$`)
|
||||
|
||||
// 翻译里的标签行和 // 占位行(该行无翻译)不参与前端配对。
|
||||
func cleanTrans(lrc string) string {
|
||||
var b strings.Builder
|
||||
for _, row := range strings.Split(lrc, "\n") {
|
||||
m := transLineRe.FindStringSubmatch(strings.TrimSpace(row))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if body := strings.TrimSpace(m[1]); body == "" || body == "//" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(row)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return text(b.String())
|
||||
}
|
||||
|
||||
func hasWordTimings(lines []qrc.Line) bool {
|
||||
for _, l := range lines {
|
||||
if len(l.Words) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func song(mid, title string, singers []singer, album, albumMID string, seconds int64) port.Song {
|
||||
names := make([]string, 0, len(singers))
|
||||
for _, s := range singers {
|
||||
|
||||
@@ -2,9 +2,10 @@ package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
@@ -24,6 +26,8 @@ import (
|
||||
|
||||
const testCookie = "qq_test_cookie=fictitious"
|
||||
|
||||
var testLogger = slog.New(slog.DiscardHandler)
|
||||
|
||||
// 测试服务器按路径返回 testdata 中录制的响应,Client 的所有请求都转到它。
|
||||
func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed) {
|
||||
t.Helper()
|
||||
@@ -48,6 +52,7 @@ func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed
|
||||
func(context.Context) (string, error) { return testCookie, nil },
|
||||
func(context.Context, string) error { return nil },
|
||||
obs.record,
|
||||
testLogger,
|
||||
), obs
|
||||
}
|
||||
|
||||
@@ -185,46 +190,75 @@ func TestTrackMeta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 歌曲的元数据和歌词会写入 PostgreSQL,text 类型不能存储 U+0000,歌词是 base64 编码的,要在解码之后清除。
|
||||
// 歌曲的元数据和歌词会写入 PostgreSQL,text 类型不能存储 U+0000,要在解密之后清除。
|
||||
func TestTextDropsNUL(t *testing.T) {
|
||||
lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]故\x00事"))
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{
|
||||
"/v8/fcg-bin/fcg_play_single_song.fcg": body(`{"code":0,"data":[{"mid":"004Fs2FP1EvZYc","title":"晴\u0000天","interval":249,"album":{"mid":"004fXS\u0000yj3bWTMN","name":"叶\u0000惠美"},"singer":[{"name":"周\u0000杰伦"}]}]}`),
|
||||
"/lyric/fcgi-bin/fcg_query_lyric_new.fcg": body(`{"code":0,"lyric":"` + lyric + `"}`),
|
||||
"/v8/fcg-bin/fcg_play_single_song.fcg": body(`{"code":0,"data":[{"mid":"004Fs2FP1EvZYc","title":"晴\u0000天","interval":249,"album":{"mid":"004fXS\u0000yj3bWTMN","name":"叶\u0000惠美"},"singer":[{"name":"周\u0000杰伦"}]}]}`),
|
||||
"/cgi-bin/musicu.fcg": fixture(t, "lyric-nul.json"),
|
||||
})
|
||||
want := wantSong("004Fs2FP1EvZYc", "晴天", "叶惠美", "004fXSyj3bWTMN", 249).Meta
|
||||
if got, err := c.TrackMeta(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != want {
|
||||
t.Errorf("元数据 = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != "[00:01.00]故事" {
|
||||
t.Errorf("歌词 = %q, %v", got, err)
|
||||
if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got.LRC != "[00:01.00]故事\n" {
|
||||
t.Errorf("歌词 = %q, %v", got.LRC, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 歌词是 base64 编码的字节,不保证是 UTF-8,比如 GBK 编码的歌词;PostgreSQL 的 text 类型拒绝不合法的 UTF-8,
|
||||
// 解密后的歌词不保证是 UTF-8,比如 GBK 编码的歌词;PostgreSQL 的 text 类型拒绝不合法的 UTF-8,
|
||||
// 这样的歌词存不进去,准备任务每次都失败。
|
||||
func TestLyricReplacesInvalidUTF8(t *testing.T) {
|
||||
lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]\xb9\xca\xca\xc2\n[00:02.00]晴天"))
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{
|
||||
"/lyric/fcgi-bin/fcg_query_lyric_new.fcg": body(`{"code":0,"lyric":"` + lyric + `"}`),
|
||||
"/cgi-bin/musicu.fcg": fixture(t, "lyric-gbk.json"),
|
||||
})
|
||||
if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != "[00:01.00]\uFFFD\n[00:02.00]晴天" {
|
||||
t.Errorf("歌词 = %q, %v,期望不合法的字节替换为 U+FFFD", got, err)
|
||||
if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got.LRC != "[00:01.00]\uFFFD\n[00:02.00]晴天" {
|
||||
t.Errorf("歌词 = %q, %v,期望不合法的字节替换为 U+FFFD", got.LRC, err)
|
||||
}
|
||||
}
|
||||
|
||||
const audioPath = "/qq/80456317.flac"
|
||||
|
||||
// musicu 网关同时承载歌词和播放地址:按请求里的 req_1(播放地址)有无分发打桩。
|
||||
func musicu(t *testing.T, playResponse string) http.HandlerFunc {
|
||||
return musicuLyric(t, playResponse, fixture(t, "lyric.json"))
|
||||
}
|
||||
|
||||
func musicuLyric(t *testing.T, playResponse string, lyric http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Cookie"); got != testCookie {
|
||||
t.Errorf("musicu 接口的 Cookie = %q,期望虚构 QQ Cookie", got)
|
||||
}
|
||||
var payload struct {
|
||||
Req1 *struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"req_1"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r.URL.Query().Get("data")), &payload); err != nil {
|
||||
t.Errorf("解析 musicu 请求:%v", err)
|
||||
}
|
||||
if payload.Req1 != nil {
|
||||
body(playResponse)(w, r)
|
||||
return
|
||||
}
|
||||
lyric(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// playRequest 只把播放地址请求转给打桩,歌词仍走固定件。
|
||||
func playRequest(t *testing.T, handle http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Query().Get("data"), "req_1") {
|
||||
handle(w, r)
|
||||
return
|
||||
}
|
||||
fixture(t, "lyric.json")(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRoutes(t *testing.T) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/v8/fcg-bin/fcg_play_single_song.fcg": fixture(t, "song-detail.json"),
|
||||
"/lyric/fcgi-bin/fcg_query_lyric_new.fcg": fixture(t, "lyric.json"),
|
||||
"/cgi-bin/musicu.fcg": func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Cookie"); got != testCookie {
|
||||
t.Errorf("QQ 播放地址接口的 Cookie = %q,期望虚构 QQ Cookie", got)
|
||||
}
|
||||
body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`)(w, r)
|
||||
},
|
||||
"/v8/fcg-bin/fcg_play_single_song.fcg": fixture(t, "song-detail.json"),
|
||||
"/cgi-bin/musicu.fcg": musicu(t, `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`),
|
||||
audioPath: func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Cookie"); got != "" {
|
||||
t.Errorf("音频 CDN 收到了 Cookie %q", got)
|
||||
@@ -236,19 +270,17 @@ func resolveRoutes(t *testing.T) map[string]http.HandlerFunc {
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Cookie"); got != testCookie {
|
||||
t.Errorf("QQ 播放地址接口的 Cookie = %q,期望虚构 QQ Cookie", got)
|
||||
}
|
||||
body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"F000004Fs2FP1EvZYc004Fs2FP1EvZYc.flac","purl":"`+audioPath[1:]+`"}]}}}`)(w, r)
|
||||
}
|
||||
routes["/cgi-bin/musicu.fcg"] = musicu(t, `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"F000004Fs2FP1EvZYc004Fs2FP1EvZYc.flac","purl":"`+audioPath[1:]+`"}]}}}`)
|
||||
c, obs := serve(t, routes)
|
||||
got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = got.Audio.Close() }()
|
||||
if got.Meta != live.Meta || got.Lyric != "[ti:晴天]\n[ar:周杰伦]\n[00:00.00]晴天 - 周杰伦 (Jay Chou)\n" {
|
||||
wantLyric := "[ti:逐字测试]\n[ar:歌手]\n[00:01.00]你好世\n[00:03.00]再见\n"
|
||||
wantWords := "[1000,1500](1000,500,0)你(1500,500,0)好(2000,500,0)世\n[3000,1000](3000,500,0)再(3500,500,0)见\n"
|
||||
wantTrans := "[00:01.00]你好世界\n[00:03.00]再见\n"
|
||||
if got.Meta != live.Meta || got.Lyric != wantLyric || got.LyricWords != wantWords || got.LyricTrans != wantTrans {
|
||||
t.Errorf("解析结果 = %#v", got)
|
||||
}
|
||||
if b, err := io.ReadAll(got.Audio); err != nil || string(b) != "ID3 音频" {
|
||||
@@ -268,7 +300,7 @@ func TestResolveRejects(t *testing.T) {
|
||||
func TestResolveRetriesUpstreamFailures(t *testing.T) {
|
||||
var rejected *port.RejectedError
|
||||
routes := resolveRoutes(t)
|
||||
routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }
|
||||
routes["/cgi-bin/musicu.fcg"] = playRequest(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) })
|
||||
c, obs := serve(t, routes)
|
||||
if _, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc"); err == nil || errors.As(err, &rejected) {
|
||||
t.Errorf("QQ 原生播放接口出错时的错误 = %v,期望可以重试的错误", err)
|
||||
@@ -277,9 +309,9 @@ func TestResolveRetriesUpstreamFailures(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLyricMissing(t *testing.T) {
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{"/lyric/fcgi-bin/fcg_query_lyric_new.fcg": fixture(t, "lyric-missing.json")})
|
||||
if got, err := c.lyric(t.Context(), "000000000000xx"); err != nil || got != "" {
|
||||
t.Errorf("没有歌词的歌 = %q, %v,期望空字符串", got, err)
|
||||
c, _ := serve(t, map[string]http.HandlerFunc{"/cgi-bin/musicu.fcg": fixture(t, "lyric-missing.json")})
|
||||
if got, err := c.lyric(t.Context(), "000000000000xx"); err != nil || got != (lyricResult{}) {
|
||||
t.Errorf("没有歌词的歌 = %#v, %v,期望空结果", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,3 +397,11 @@ func TestCookiePutUpdatesCache(t *testing.T) {
|
||||
t.Errorf("保存的 Cookie = %q,重新加载了 %d 次", saved, loads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTransDropsTagsAndPlaceholders(t *testing.T) {
|
||||
in := "[ti:标题]\n[00:00.00]//\n[00:01.00]第一句\n[00:02.00] // \n[00:03.00]第二句\n"
|
||||
want := "[00:01.00]第一句\n[00:03.00]第二句\n"
|
||||
if got := cleanTrans(in); got != want {
|
||||
t.Errorf("清理后的翻译 = %q,期望 %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package qrc 解密并解析 QQ 音乐的 QRC 歌词:密文是十六进制字符串,
|
||||
// 加密为 QQ 改版 DES 的三轮 EDE,解压为 zlib。改版 DES 与标准 DES 不兼容:
|
||||
// 32 位字内字节小端、两个 S 盒条目被改动、PC-2 的 D 半区错位一位。
|
||||
package qrc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var qrcKey = []byte("!@#)(*$%123ZXC!@!@#)(NHL")
|
||||
|
||||
// DecryptHex 解密十六进制的 QRC 密文。
|
||||
func DecryptHex(content string) (string, error) {
|
||||
encrypted, err := hex.DecodeString(strings.TrimSpace(content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("QRC 密文不是十六进制:%w", err)
|
||||
}
|
||||
if len(encrypted)%8 != 0 {
|
||||
return "", fmt.Errorf("QRC 密文长度 %d 不是 8 的倍数", len(encrypted))
|
||||
}
|
||||
plain := make([]byte, len(encrypted))
|
||||
for i := 0; i < len(encrypted); i += 8 {
|
||||
copy(plain[i:i+8], tripleDESDecrypt(encrypted[i:i+8]))
|
||||
}
|
||||
r, err := zlib.NewReader(bytes.NewReader(plain))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("QRC 解压:%w", err)
|
||||
}
|
||||
text, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("QRC 解压:%w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(text)), nil
|
||||
}
|
||||
|
||||
const (
|
||||
modeDecrypt = 0
|
||||
modeEncrypt = 1
|
||||
)
|
||||
|
||||
func tripleDESDecrypt(block []byte) []byte {
|
||||
keys := [3][16][6]byte{
|
||||
keySchedule(qrcKey[16:], modeDecrypt),
|
||||
keySchedule(qrcKey[8:], modeEncrypt),
|
||||
keySchedule(qrcKey[0:], modeDecrypt),
|
||||
}
|
||||
data := append([]byte(nil), block...)
|
||||
for i := 0; i < 3; i++ {
|
||||
data = desCrypt(data, keys[i])
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// bitNum 取输入中第 b 位(逻辑序号),放到结果的 c 位上;逻辑字节 n 在物理字节 (n/4)*4+3-n%4。
|
||||
func bitNum(a []byte, b, c int) uint32 {
|
||||
return uint32((a[(b/32)*4+3-(b%32)/8]>>(7-b%8))&1) << c
|
||||
}
|
||||
|
||||
func bitNumIntr(a uint32, b, c int) uint32 {
|
||||
return ((a >> (31 - b)) & 1) << c
|
||||
}
|
||||
|
||||
func bitNumIntl(a uint32, b, c int) uint32 {
|
||||
return ((a << b) & 0x80000000) >> c
|
||||
}
|
||||
|
||||
func sBoxBit(a byte) byte {
|
||||
return (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4)
|
||||
}
|
||||
|
||||
func initialPermutation(input []byte) (uint32, uint32) {
|
||||
return bitNum(input, 57, 31) | bitNum(input, 49, 30) | bitNum(input, 41, 29) | bitNum(input, 33, 28) |
|
||||
bitNum(input, 25, 27) | bitNum(input, 17, 26) | bitNum(input, 9, 25) | bitNum(input, 1, 24) |
|
||||
bitNum(input, 59, 23) | bitNum(input, 51, 22) | bitNum(input, 43, 21) | bitNum(input, 35, 20) |
|
||||
bitNum(input, 27, 19) | bitNum(input, 19, 18) | bitNum(input, 11, 17) | bitNum(input, 3, 16) |
|
||||
bitNum(input, 61, 15) | bitNum(input, 53, 14) | bitNum(input, 45, 13) | bitNum(input, 37, 12) |
|
||||
bitNum(input, 29, 11) | bitNum(input, 21, 10) | bitNum(input, 13, 9) | bitNum(input, 5, 8) |
|
||||
bitNum(input, 63, 7) | bitNum(input, 55, 6) | bitNum(input, 47, 5) | bitNum(input, 39, 4) |
|
||||
bitNum(input, 31, 3) | bitNum(input, 23, 2) | bitNum(input, 15, 1) | bitNum(input, 7, 0),
|
||||
bitNum(input, 56, 31) | bitNum(input, 48, 30) | bitNum(input, 40, 29) | bitNum(input, 32, 28) |
|
||||
bitNum(input, 24, 27) | bitNum(input, 16, 26) | bitNum(input, 8, 25) | bitNum(input, 0, 24) |
|
||||
bitNum(input, 58, 23) | bitNum(input, 50, 22) | bitNum(input, 42, 21) | bitNum(input, 34, 20) |
|
||||
bitNum(input, 26, 19) | bitNum(input, 18, 18) | bitNum(input, 10, 17) | bitNum(input, 2, 16) |
|
||||
bitNum(input, 60, 15) | bitNum(input, 52, 14) | bitNum(input, 44, 13) | bitNum(input, 36, 12) |
|
||||
bitNum(input, 28, 11) | bitNum(input, 20, 10) | bitNum(input, 12, 9) | bitNum(input, 4, 8) |
|
||||
bitNum(input, 62, 7) | bitNum(input, 54, 6) | bitNum(input, 46, 5) | bitNum(input, 38, 4) |
|
||||
bitNum(input, 30, 3) | bitNum(input, 22, 2) | bitNum(input, 14, 1) | bitNum(input, 6, 0)
|
||||
}
|
||||
|
||||
func inversePermutation(s0, s1 uint32) []byte {
|
||||
data := make([]byte, 8)
|
||||
data[3] = byte(bitNumIntr(s1, 7, 7) | bitNumIntr(s0, 7, 6) | bitNumIntr(s1, 15, 5) | bitNumIntr(s0, 15, 4) | bitNumIntr(s1, 23, 3) | bitNumIntr(s0, 23, 2) | bitNumIntr(s1, 31, 1) | bitNumIntr(s0, 31, 0))
|
||||
data[2] = byte(bitNumIntr(s1, 6, 7) | bitNumIntr(s0, 6, 6) | bitNumIntr(s1, 14, 5) | bitNumIntr(s0, 14, 4) | bitNumIntr(s1, 22, 3) | bitNumIntr(s0, 22, 2) | bitNumIntr(s1, 30, 1) | bitNumIntr(s0, 30, 0))
|
||||
data[1] = byte(bitNumIntr(s1, 5, 7) | bitNumIntr(s0, 5, 6) | bitNumIntr(s1, 13, 5) | bitNumIntr(s0, 13, 4) | bitNumIntr(s1, 21, 3) | bitNumIntr(s0, 21, 2) | bitNumIntr(s1, 29, 1) | bitNumIntr(s0, 29, 0))
|
||||
data[0] = byte(bitNumIntr(s1, 4, 7) | bitNumIntr(s0, 4, 6) | bitNumIntr(s1, 12, 5) | bitNumIntr(s0, 12, 4) | bitNumIntr(s1, 20, 3) | bitNumIntr(s0, 20, 2) | bitNumIntr(s1, 28, 1) | bitNumIntr(s0, 28, 0))
|
||||
data[7] = byte(bitNumIntr(s1, 3, 7) | bitNumIntr(s0, 3, 6) | bitNumIntr(s1, 11, 5) | bitNumIntr(s0, 11, 4) | bitNumIntr(s1, 19, 3) | bitNumIntr(s0, 19, 2) | bitNumIntr(s1, 27, 1) | bitNumIntr(s0, 27, 0))
|
||||
data[6] = byte(bitNumIntr(s1, 2, 7) | bitNumIntr(s0, 2, 6) | bitNumIntr(s1, 10, 5) | bitNumIntr(s0, 10, 4) | bitNumIntr(s1, 18, 3) | bitNumIntr(s0, 18, 2) | bitNumIntr(s1, 26, 1) | bitNumIntr(s0, 26, 0))
|
||||
data[5] = byte(bitNumIntr(s1, 1, 7) | bitNumIntr(s0, 1, 6) | bitNumIntr(s1, 9, 5) | bitNumIntr(s0, 9, 4) | bitNumIntr(s1, 17, 3) | bitNumIntr(s0, 17, 2) | bitNumIntr(s1, 25, 1) | bitNumIntr(s0, 25, 0))
|
||||
data[4] = byte(bitNumIntr(s1, 0, 7) | bitNumIntr(s0, 0, 6) | bitNumIntr(s1, 8, 5) | bitNumIntr(s0, 8, 4) | bitNumIntr(s1, 16, 3) | bitNumIntr(s0, 16, 2) | bitNumIntr(s1, 24, 1) | bitNumIntr(s0, 24, 0))
|
||||
return data
|
||||
}
|
||||
|
||||
func feistel(state uint32, key [6]byte) uint32 {
|
||||
t1 := bitNumIntl(state, 31, 0) | ((state & 0xf0000000) >> 1) | bitNumIntl(state, 4, 5) |
|
||||
bitNumIntl(state, 3, 6) | ((state & 0x0f000000) >> 3) | bitNumIntl(state, 8, 11) |
|
||||
bitNumIntl(state, 7, 12) | ((state & 0x00f00000) >> 5) | bitNumIntl(state, 12, 17) |
|
||||
bitNumIntl(state, 11, 18) | ((state & 0x000f0000) >> 7) | bitNumIntl(state, 16, 23)
|
||||
t2 := bitNumIntl(state, 15, 0) | ((state & 0x0000f000) << 15) | bitNumIntl(state, 20, 5) |
|
||||
bitNumIntl(state, 19, 6) | ((state & 0x00000f00) << 13) | bitNumIntl(state, 24, 11) |
|
||||
bitNumIntl(state, 23, 12) | ((state & 0x000000f0) << 11) | bitNumIntl(state, 28, 17) |
|
||||
bitNumIntl(state, 27, 18) | ((state & 0x0000000f) << 9) | bitNumIntl(state, 0, 23)
|
||||
lrg := [6]byte{byte(t1 >> 24), byte(t1 >> 16), byte(t1 >> 8), byte(t2 >> 24), byte(t2 >> 16), byte(t2 >> 8)}
|
||||
for i := range lrg {
|
||||
lrg[i] ^= key[i]
|
||||
}
|
||||
state = uint32(sBox[0][sBoxBit(lrg[0]>>2)])<<28 |
|
||||
uint32(sBox[1][sBoxBit(((lrg[0]&0x03)<<4)|(lrg[1]>>4))])<<24 |
|
||||
uint32(sBox[2][sBoxBit(((lrg[1]&0x0f)<<2)|(lrg[2]>>6))])<<20 |
|
||||
uint32(sBox[3][sBoxBit(lrg[2]&0x3f)])<<16 |
|
||||
uint32(sBox[4][sBoxBit(lrg[3]>>2)])<<12 |
|
||||
uint32(sBox[5][sBoxBit(((lrg[3]&0x03)<<4)|(lrg[4]>>4))])<<8 |
|
||||
uint32(sBox[6][sBoxBit(((lrg[4]&0x0f)<<2)|(lrg[5]>>6))])<<4 |
|
||||
uint32(sBox[7][sBoxBit(lrg[5]&0x3f)])
|
||||
return bitNumIntl(state, 15, 0) | bitNumIntl(state, 6, 1) | bitNumIntl(state, 19, 2) |
|
||||
bitNumIntl(state, 20, 3) | bitNumIntl(state, 28, 4) | bitNumIntl(state, 11, 5) |
|
||||
bitNumIntl(state, 27, 6) | bitNumIntl(state, 16, 7) | bitNumIntl(state, 0, 8) |
|
||||
bitNumIntl(state, 14, 9) | bitNumIntl(state, 22, 10) | bitNumIntl(state, 25, 11) |
|
||||
bitNumIntl(state, 4, 12) | bitNumIntl(state, 17, 13) | bitNumIntl(state, 30, 14) |
|
||||
bitNumIntl(state, 9, 15) | bitNumIntl(state, 1, 16) | bitNumIntl(state, 7, 17) |
|
||||
bitNumIntl(state, 23, 18) | bitNumIntl(state, 13, 19) | bitNumIntl(state, 31, 20) |
|
||||
bitNumIntl(state, 26, 21) | bitNumIntl(state, 2, 22) | bitNumIntl(state, 8, 23) |
|
||||
bitNumIntl(state, 18, 24) | bitNumIntl(state, 12, 25) | bitNumIntl(state, 29, 26) |
|
||||
bitNumIntl(state, 5, 27) | bitNumIntl(state, 21, 28) | bitNumIntl(state, 10, 29) |
|
||||
bitNumIntl(state, 3, 30) | bitNumIntl(state, 24, 31)
|
||||
}
|
||||
|
||||
func desCrypt(input []byte, key [16][6]byte) []byte {
|
||||
s0, s1 := initialPermutation(input)
|
||||
for i := 0; i < 15; i++ {
|
||||
prev := s1
|
||||
s1 = feistel(s1, key[i]) ^ s0
|
||||
s0 = prev
|
||||
}
|
||||
s0 = feistel(s1, key[15]) ^ s0
|
||||
return inversePermutation(s0, s1)
|
||||
}
|
||||
|
||||
func keySchedule(key []byte, mode int) [16][6]byte {
|
||||
var schedule [16][6]byte
|
||||
roundShift := [16]int{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}
|
||||
permC := [28]int{56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35}
|
||||
permD := [28]int{62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3}
|
||||
compression := [48]int{13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31}
|
||||
var c, d uint32
|
||||
for i := 0; i < 28; i++ {
|
||||
c += bitNum(key, permC[i], 31-i)
|
||||
d += bitNum(key, permD[i], 31-i)
|
||||
}
|
||||
for i := 0; i < 16; i++ {
|
||||
shift := roundShift[i]
|
||||
c = ((c << shift) | (c >> (28 - shift))) & 0xfffffff0
|
||||
d = ((d << shift) | (d >> (28 - shift))) & 0xfffffff0
|
||||
toGen := i
|
||||
if mode == modeDecrypt {
|
||||
toGen = 15 - i
|
||||
}
|
||||
for j := 0; j < 24; j++ {
|
||||
schedule[toGen][j/8] |= byte(bitNumIntr(c, compression[j], 7-(j%8)))
|
||||
}
|
||||
for j := 24; j < 48; j++ {
|
||||
schedule[toGen][j/8] |= byte(bitNumIntr(d, compression[j]-27, 7-(j%8)))
|
||||
}
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package qrc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Word 是一段带独立时间的歌词片段,通常是单个字。毫秒,绝对于曲目起点。
|
||||
type Word struct {
|
||||
StartMs int
|
||||
DurationMs int
|
||||
Text string
|
||||
}
|
||||
|
||||
// Line 是一行逐字歌词。毫秒,绝对于曲目起点。
|
||||
type Line struct {
|
||||
StartMs int
|
||||
DurationMs int
|
||||
Words []Word
|
||||
}
|
||||
|
||||
// Text 拼接整行文本。
|
||||
func (l Line) Text() string {
|
||||
var b strings.Builder
|
||||
for _, w := range l.Words {
|
||||
b.WriteString(w.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var (
|
||||
contentRe = regexp.MustCompile(`(?s)<Lyric_1[^>]*LyricContent="(.*?)"\s*/>`)
|
||||
tagRe = regexp.MustCompile(`^\[([A-Za-z]+):([^\]]*)\]$`)
|
||||
lineRe = regexp.MustCompile(`^\[(\d+),(\d+)\](.*)$`)
|
||||
timingRe = regexp.MustCompile(`^\d+,\d+$`)
|
||||
)
|
||||
|
||||
// QRC 的时间标记跟在它所定时的文本之后;只有内容是纯数字时间的圆括号才是标记,其余按歌词原文保留。
|
||||
func parseWords(content string) []Word {
|
||||
var words []Word
|
||||
start := 0
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] != '(' {
|
||||
continue
|
||||
}
|
||||
end := strings.IndexByte(content[i:], ')')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
inner := content[i+1 : i+end]
|
||||
if !timingRe.MatchString(inner) {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSuffix(content[start:i], "\r")
|
||||
if text != "" {
|
||||
parts := strings.SplitN(inner, ",", 2)
|
||||
words = append(words, Word{StartMs: atoi(parts[0]), DurationMs: atoi(parts[1]), Text: text})
|
||||
}
|
||||
i += end
|
||||
start = i + 1
|
||||
}
|
||||
if tail := strings.TrimSpace(content[start:]); tail != "" && len(words) > 0 {
|
||||
last := words[len(words)-1]
|
||||
words = append(words, Word{StartMs: last.StartMs + last.DurationMs, Text: tail})
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// Parse 解析解密后的 QRC 文本:剥掉 XML 外壳,返回头部标签和逐字行;kana 等注音注解行被丢弃。
|
||||
func Parse(text string) (map[string]string, []Line) {
|
||||
if m := contentRe.FindStringSubmatch(text); m != nil {
|
||||
text = html.UnescapeString(m[1])
|
||||
}
|
||||
tags := map[string]string{}
|
||||
var lines []Line
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
row := strings.TrimSpace(raw)
|
||||
if row == "" {
|
||||
continue
|
||||
}
|
||||
if m := tagRe.FindStringSubmatch(row); m != nil {
|
||||
tags[m[1]] = m[2]
|
||||
continue
|
||||
}
|
||||
m := lineRe.FindStringSubmatch(row)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
line := Line{StartMs: atoi(m[1]), DurationMs: atoi(m[2]), Words: parseWords(m[3])}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return tags, lines
|
||||
}
|
||||
|
||||
// RenderYRC 输出传输用的逐字格式(YRC 语法):[行起始,行时长](字起始,字时长,0)字…,毫秒为绝对时间。
|
||||
func RenderYRC(lines []Line) string {
|
||||
var b strings.Builder
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(&b, "[%d,%d]", l.StartMs, l.DurationMs)
|
||||
for _, w := range l.Words {
|
||||
fmt.Fprintf(&b, "(%d,%d,0)%s", w.StartMs, w.DurationMs, w.Text)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderLRC 输出行级 LRC:头部标签行在前,随后是 [分:秒.厘秒]行文本。
|
||||
func RenderLRC(tags map[string]string, lines []Line) string {
|
||||
var b strings.Builder
|
||||
for _, k := range []string{"ti", "ar", "al", "by", "offset"} {
|
||||
if v, ok := tags[k]; ok {
|
||||
fmt.Fprintf(&b, "[%s:%s]\n", k, v)
|
||||
}
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(&b, "[%02d:%05.2f]%s\n", l.StartMs/60000, float64(l.StartMs%60000)/1000, l.Text())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package qrc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 合成的 QRC 明文与对应密文(由实现自身的加密方向生成,并已与真实上游响应对照验证过算法)。
|
||||
// 不用真实歌词做固定件:歌词文本有版权,不随仓库分发。
|
||||
const syntheticHex = "00e89b5b5297130317c5ab48f1bc398427fd116a7f781c1dcaba11f091bae35bce4e9e7a7f21d22faf11f3df6f877a90322a503bb5255e44f12d71001af063e612c430215267ba372156edff0bac9efeed9f0406a00f9d17f871cd9d6eaf6216286a7994ae27ca58f71c683d283e92209d036ffede8968c1f27f423a4699c6ca6bf0f54437fb0db6401288906def72ff55ce2697d3847b837bebbc018995724dd9a2d7fbf34727541ebaf8af8e8c57cf9a233d8cc2290aa2bab3a11b075fa27867ebd472fc2900e7f939cd03c64a12a36261c64cfe31f488c3b1d9fa5e397f5a66a7063aecdfab30ce6c065fc2bb6cf1231f2d1d7f64747d75be30c4599be937fab61467621a4b788a9d6e3f363f5c7befabca8bce3a42cabee6fdfbbf716e3d550bc006072cc57d9747cb873e23df8546b8077dc5b7e1723ae5f4b8ae0d76bb06832509e92dc31dfc7407a70e5bb568e8ed2c17b46d153a0ca277660e4caf1c"
|
||||
|
||||
const syntheticPlain = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<QrcInfos>
|
||||
<QrcHeadInfo SaveTime="1" Version="100"/>
|
||||
<LyricInfo LyricCount="1">
|
||||
<Lyric_1 LyricType="1" LyricContent="[ti:合成测试歌]
|
||||
[ar:虚构歌手]
|
||||
[al:不存在的专辑]
|
||||
[by:]
|
||||
[offset:0]
|
||||
[kana:1ce4试1shi4例]
|
||||
[1000,1500]你(1000,500)好(1500,500)世(2000,500)
|
||||
[3000,1200]括(3000,200)号(3200,200)((3400,200)内(3600,200)容(3800,200))(4000,200)
|
||||
[5000,1000]再(5000,500)见(5500,500)
|
||||
"/>
|
||||
</LyricInfo>
|
||||
</QrcInfos>`
|
||||
|
||||
func TestDecryptHexGolden(t *testing.T) {
|
||||
got, err := DecryptHex(syntheticHex)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败:%v", err)
|
||||
}
|
||||
if got != syntheticPlain {
|
||||
t.Errorf("解密结果与固定明文不一致:%.80q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptHexRejectsBadInput(t *testing.T) {
|
||||
if _, err := DecryptHex("not-hex"); err == nil {
|
||||
t.Error("非十六进制输入没有报错")
|
||||
}
|
||||
if _, err := DecryptHex("0011"); err == nil {
|
||||
t.Error("长度不是 8 的倍数没有报错")
|
||||
}
|
||||
if _, err := DecryptHex(strings.Repeat("00", 16)); err == nil {
|
||||
t.Error("非 zlib 数据没有报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndRender(t *testing.T) {
|
||||
tags, lines := Parse(syntheticPlain)
|
||||
if tags["ti"] != "合成测试歌" {
|
||||
t.Errorf("ti 标签 = %q", tags["ti"])
|
||||
}
|
||||
if _, ok := tags["kana"]; !ok {
|
||||
t.Error("kana 注解行应按标签处理")
|
||||
}
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("解析出 %d 行,期望 3 行", len(lines))
|
||||
}
|
||||
line := lines[0]
|
||||
if line.StartMs != 1000 || line.DurationMs != 1500 || line.Text() != "你好世" {
|
||||
t.Errorf("首行 = [%d,%d]%q", line.StartMs, line.DurationMs, line.Text())
|
||||
}
|
||||
if len(line.Words) != 3 || line.Words[0] != (Word{StartMs: 1000, DurationMs: 500, Text: "你"}) {
|
||||
t.Errorf("首行逐字 = %#v", line.Words)
|
||||
}
|
||||
// 歌词原文里的全角圆括号不是时间标记,要保留在文本里。
|
||||
if text := lines[1].Text(); text != "括号(内容)" {
|
||||
t.Errorf("含括号的行 = %q", text)
|
||||
}
|
||||
|
||||
lrc := RenderLRC(tags, lines)
|
||||
if !strings.Contains(lrc, "[ti:合成测试歌]\n") || !strings.Contains(lrc, "[00:01.00]你好世\n") {
|
||||
t.Errorf("LRC 渲染缺少预期行:%.120q", lrc)
|
||||
}
|
||||
yrc := RenderYRC(lines)
|
||||
if !strings.Contains(yrc, "[1000,1500](1000,500,0)你(1500,500,0)好(2000,500,0)世\n") {
|
||||
t.Errorf("YRC 渲染缺少预期行:%.120q", yrc)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有逐字时间标记的内容(纯 LRC 文本)解析不出行。
|
||||
func TestParsePlainLRCYieldsNoLines(t *testing.T) {
|
||||
_, lines := Parse("[00:01.00]普通歌词\n[00:03.00]第二行\n")
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("纯 LRC 解析出 %d 行,期望 0", len(lines))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package qrc
|
||||
|
||||
var sBox = [8][64]byte{
|
||||
{14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13},
|
||||
{15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9},
|
||||
{10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12},
|
||||
{7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14},
|
||||
{2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3},
|
||||
{12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13},
|
||||
{4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12},
|
||||
{13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"request":{"code":0,"data":{"lyric":"05859e276a59efb79776197f2e7e94bd1641dad6de9769283c963ddbb39fee8d55e201e8dcf0642ff9b4955a54a1a83f","trans":""}}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"retcode":1101,"code":1101,"subcode":1101}
|
||||
{"code":0,"request":{"code":0,"data":{"lyric":"","trans":""}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code":0,"request":{"code":0,"data":{"lyric":"567c4df61c8126e928c4206b1238164d00fc55d687733f4d7f9d0808a5ccb95fafd82d86e5a3be691ce6c7938220bd8871fd10c81ede12d1","trans":""}}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"retcode":0,"code":0,"subcode":0,"lyric":"W3RpOuaZtOWkqV0KW2FyOuWRqOadsOS8pl0KWzAwOjAwLjAwXeaZtOWkqSAtIOWRqOadsOS8piAoSmF5IENob3UpCg=="}
|
||||
{"code":0,"request":{"code":0,"data":{"lyric":"9f805844e44d85137f363bf13b6cbdeefd9467d25470a77f7ac8a94038f52c954933e38a06674b301004d53b7ae404892dc3dc02e0bfcaf65f779221b2038470e1d0783c9cbc166e3bece62062873db6ffd651c6e2aaf93562f3d4daee6242fb7f0d0c78ad57ffe97f9d0808a5ccb95f92dbe8bb1fb3a2d559ab888e59f7916aa63eaecfd024155f","trans":"3202b47e88d10c209776197f2e7e94bdc629e98a4fb99b02cb1c6054ae4423ea40fe03e017f80d6701714edab4b127a55082f25280299d31"}}}
|
||||
@@ -87,7 +87,10 @@ type (
|
||||
Resolved struct {
|
||||
Meta track.Meta
|
||||
Lyric string
|
||||
Audio io.ReadCloser
|
||||
// 逐字歌词与翻译歌词,格式同 track.Prepared 的对应字段,可为空。
|
||||
LyricWords string
|
||||
LyricTrans string
|
||||
Audio io.ReadCloser
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func (r *Runner) Run(ctx context.Context, t track.Track) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.LyricWords, result.LyricTrans = resolved.LyricWords, resolved.LyricTrans
|
||||
ctx, cancel := context.WithTimeout(ctx, storeTimeout)
|
||||
defer cancel()
|
||||
return r.tracks.SavePreparedTrack(ctx, t, port.PreparedTrack{Meta: resolved.Meta, Result: result})
|
||||
|
||||
@@ -75,8 +75,9 @@ func runServe(base *slog.Logger) (err error) {
|
||||
store := postgres.New(pool)
|
||||
// 每个请求的期限由音源适配器设置。
|
||||
upstream := &http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)}
|
||||
neteaseSource := netease.New(upstream, store.NeteaseCookie, store.SetNeteaseCookie, observed.provider)
|
||||
qqSource := qq.New(upstream, store.QQCookie, store.SetQQCookie, observed.provider)
|
||||
sourceLogger := base.With("component", "source")
|
||||
neteaseSource := netease.New(upstream, store.NeteaseCookie, store.SetNeteaseCookie, observed.provider, sourceLogger)
|
||||
qqSource := qq.New(upstream, store.QQCookie, store.SetQQCookie, observed.provider, sourceLogger)
|
||||
// serve 不下载文件,下载由 worker 在自己的工作目录中进行。
|
||||
bilibiliSource := bilibili.New(upstream, "", store.BilibiliCredential, store.SetBilibiliCredential, observed.provider)
|
||||
src := sources{
|
||||
|
||||
@@ -58,4 +58,8 @@ type Prepared struct {
|
||||
Duration time.Duration
|
||||
Gain float64
|
||||
Lyric string
|
||||
// 逐字歌词,YRC 语法:[行起始ms,行时长ms](字起始ms,字时长ms,0)字…,绝对毫秒,无头部标签行。可为空。
|
||||
LyricWords string
|
||||
// 翻译歌词,行级 LRC,时间戳与 Lyric/LyricWords 的行起点对应(允许毫秒级偏差)。可为空。
|
||||
LyricTrans string
|
||||
}
|
||||
Reference in new issue
Block a user