From bec757c7a503331de3564f4b2786081b51b14ccf Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 4 Nov 2026 23:50:21 +0800 Subject: [PATCH] feat: add translated and word-by-word lyrics for QQ and NetEase --- internal/adapter/out/netease/client.go | 18 +- internal/adapter/out/netease/degrade_test.go | 79 ++++++++ internal/adapter/out/netease/login_test.go | 2 +- internal/adapter/out/netease/netease.go | 83 +++++++- internal/adapter/out/netease/netease_test.go | 19 +- .../adapter/out/netease/testdata/lyric.json | 2 +- internal/adapter/out/qq/account_test.go | 2 +- internal/adapter/out/qq/client.go | 16 +- internal/adapter/out/qq/degrade_test.go | 113 +++++++++++ internal/adapter/out/qq/login_test.go | 2 + internal/adapter/out/qq/play.go | 4 +- internal/adapter/out/qq/play_test.go | 3 +- internal/adapter/out/qq/qq.go | 150 ++++++++++++--- internal/adapter/out/qq/qq_test.go | 102 +++++++--- internal/adapter/out/qq/qrc/des.go | 180 ++++++++++++++++++ internal/adapter/out/qq/qrc/qrc.go | 128 +++++++++++++ internal/adapter/out/qq/qrc/qrc_test.go | 90 +++++++++ internal/adapter/out/qq/qrc/sbox.go | 12 ++ .../adapter/out/qq/testdata/lyric-gbk.json | 1 + .../out/qq/testdata/lyric-missing.json | 2 +- .../adapter/out/qq/testdata/lyric-nul.json | 1 + internal/adapter/out/qq/testdata/lyric.json | 2 +- internal/app/port/source.go | 5 +- internal/app/prepare/run.go | 1 + internal/bootstrap/serve.go | 5 +- internal/domain/track/track.go | 4 + 26 files changed, 938 insertions(+), 88 deletions(-) create mode 100644 internal/adapter/out/netease/degrade_test.go create mode 100644 internal/adapter/out/qq/degrade_test.go create mode 100644 internal/adapter/out/qq/qrc/des.go create mode 100644 internal/adapter/out/qq/qrc/qrc.go create mode 100644 internal/adapter/out/qq/qrc/qrc_test.go create mode 100644 internal/adapter/out/qq/qrc/sbox.go create mode 100644 internal/adapter/out/qq/testdata/lyric-gbk.json create mode 100644 internal/adapter/out/qq/testdata/lyric-nul.json diff --git a/internal/adapter/out/netease/client.go b/internal/adapter/out/netease/client.go index e0cae04..2c6be59 100644 --- a/internal/adapter/out/netease/client.go +++ b/internal/adapter/out/netease/client.go @@ -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) diff --git a/internal/adapter/out/netease/degrade_test.go b/internal/adapter/out/netease/degrade_test.go new file mode 100644 index 0000000..8335c65 --- /dev/null +++ b/internal/adapter/out/netease/degrade_test.go @@ -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 没有返回错误") + } +} diff --git a/internal/adapter/out/netease/login_test.go b/internal/adapter/out/netease/login_test.go index a7243bc..5867d4b 100644 --- a/internal/adapter/out/netease/login_test.go +++ b/internal/adapter/out/netease/login_test.go @@ -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) { diff --git a/internal/adapter/out/netease/netease.go b/internal/adapter/out/netease/netease.go index bf0bbed..78c464c 100644 --- a/internal/adapter/out/netease/netease.go +++ b/internal/adapter/out/netease/netease.go @@ -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)}, diff --git a/internal/adapter/out/netease/netease_test.go b/internal/adapter/out/netease/netease_test.go index 0323610..d5af9f0 100644 --- a/internal/adapter/out/netease/netease_test.go +++ b/internal/adapter/out/netease/netease_test.go @@ -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(), "网络中断") { diff --git a/internal/adapter/out/netease/testdata/lyric.json b/internal/adapter/out/netease/testdata/lyric.json index 26ed894..936b6fa 100644 --- a/internal/adapter/out/netease/testdata/lyric.json +++ b/internal/adapter/out/netease/testdata/lyric.json @@ -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} \ No newline at end of file diff --git a/internal/adapter/out/qq/account_test.go b/internal/adapter/out/qq/account_test.go index 0db8d62..f4128f5 100644 --- a/internal/adapter/out/qq/account_test.go +++ b/internal/adapter/out/qq/account_test.go @@ -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) { diff --git a/internal/adapter/out/qq/client.go b/internal/adapter/out/qq/client.go index 4d98a61..8e8baf7 100644 --- a/internal/adapter/out/qq/client.go +++ b/internal/adapter/out/qq/client.go @@ -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) diff --git a/internal/adapter/out/qq/degrade_test.go b/internal/adapter/out/qq/degrade_test.go new file mode 100644 index 0000000..fd452cb --- /dev/null +++ b/internal/adapter/out/qq/degrade_test.go @@ -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 没有返回错误") + } +} diff --git a/internal/adapter/out/qq/login_test.go b/internal/adapter/out/qq/login_test.go index de205b0..3c7cddf 100644 --- a/internal/adapter/out/qq/login_test.go +++ b/internal/adapter/out/qq/login_test.go @@ -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 { diff --git a/internal/adapter/out/qq/play.go b/internal/adapter/out/qq/play.go index afd123f..2e78635 100644 --- a/internal/adapter/out/qq/play.go +++ b/internal/adapter/out/qq/play.go @@ -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 { diff --git a/internal/adapter/out/qq/play_test.go b/internal/adapter/out/qq/play_test.go index 057e4d8..a226332 100644 --- a/internal/adapter/out/qq/play_test.go +++ b/internal/adapter/out/qq/play_test.go @@ -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(), "网络中断") { diff --git a/internal/adapter/out/qq/qq.go b/internal/adapter/out/qq/qq.go index 8ac47a2..a4aed7b 100644 --- a/internal/adapter/out/qq/qq.go +++ b/internal/adapter/out/qq/qq.go @@ -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 { diff --git a/internal/adapter/out/qq/qq_test.go b/internal/adapter/out/qq/qq_test.go index d04cefe..f23a5de 100644 --- a/internal/adapter/out/qq/qq_test.go +++ b/internal/adapter/out/qq/qq_test.go @@ -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) + } +} diff --git a/internal/adapter/out/qq/qrc/des.go b/internal/adapter/out/qq/qrc/des.go new file mode 100644 index 0000000..1e65a9c --- /dev/null +++ b/internal/adapter/out/qq/qrc/des.go @@ -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 +} diff --git a/internal/adapter/out/qq/qrc/qrc.go b/internal/adapter/out/qq/qrc/qrc.go new file mode 100644 index 0000000..f8f3bc9 --- /dev/null +++ b/internal/adapter/out/qq/qrc/qrc.go @@ -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)]*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 +} diff --git a/internal/adapter/out/qq/qrc/qrc_test.go b/internal/adapter/out/qq/qrc/qrc_test.go new file mode 100644 index 0000000..2871141 --- /dev/null +++ b/internal/adapter/out/qq/qrc/qrc_test.go @@ -0,0 +1,90 @@ +package qrc + +import ( + "strings" + "testing" +) + +// 合成的 QRC 明文与对应密文(由实现自身的加密方向生成,并已与真实上游响应对照验证过算法)。 +// 不用真实歌词做固定件:歌词文本有版权,不随仓库分发。 +const syntheticHex = "00e89b5b5297130317c5ab48f1bc398427fd116a7f781c1dcaba11f091bae35bce4e9e7a7f21d22faf11f3df6f877a90322a503bb5255e44f12d71001af063e612c430215267ba372156edff0bac9efeed9f0406a00f9d17f871cd9d6eaf6216286a7994ae27ca58f71c683d283e92209d036ffede8968c1f27f423a4699c6ca6bf0f54437fb0db6401288906def72ff55ce2697d3847b837bebbc018995724dd9a2d7fbf34727541ebaf8af8e8c57cf9a233d8cc2290aa2bab3a11b075fa27867ebd472fc2900e7f939cd03c64a12a36261c64cfe31f488c3b1d9fa5e397f5a66a7063aecdfab30ce6c065fc2bb6cf1231f2d1d7f64747d75be30c4599be937fab61467621a4b788a9d6e3f363f5c7befabca8bce3a42cabee6fdfbbf716e3d550bc006072cc57d9747cb873e23df8546b8077dc5b7e1723ae5f4b8ae0d76bb06832509e92dc31dfc7407a70e5bb568e8ed2c17b46d153a0ca277660e4caf1c" + +const syntheticPlain = ` + + + + + +` + +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)) + } +} diff --git a/internal/adapter/out/qq/qrc/sbox.go b/internal/adapter/out/qq/qrc/sbox.go new file mode 100644 index 0000000..40fda6b --- /dev/null +++ b/internal/adapter/out/qq/qrc/sbox.go @@ -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}, +} diff --git a/internal/adapter/out/qq/testdata/lyric-gbk.json b/internal/adapter/out/qq/testdata/lyric-gbk.json new file mode 100644 index 0000000..d0fa526 --- /dev/null +++ b/internal/adapter/out/qq/testdata/lyric-gbk.json @@ -0,0 +1 @@ +{"code":0,"request":{"code":0,"data":{"lyric":"05859e276a59efb79776197f2e7e94bd1641dad6de9769283c963ddbb39fee8d55e201e8dcf0642ff9b4955a54a1a83f","trans":""}}} \ No newline at end of file diff --git a/internal/adapter/out/qq/testdata/lyric-missing.json b/internal/adapter/out/qq/testdata/lyric-missing.json index 509dc28..701c6f6 100644 --- a/internal/adapter/out/qq/testdata/lyric-missing.json +++ b/internal/adapter/out/qq/testdata/lyric-missing.json @@ -1 +1 @@ -{"retcode":1101,"code":1101,"subcode":1101} +{"code":0,"request":{"code":0,"data":{"lyric":"","trans":""}}} \ No newline at end of file diff --git a/internal/adapter/out/qq/testdata/lyric-nul.json b/internal/adapter/out/qq/testdata/lyric-nul.json new file mode 100644 index 0000000..a69e2d4 --- /dev/null +++ b/internal/adapter/out/qq/testdata/lyric-nul.json @@ -0,0 +1 @@ +{"code":0,"request":{"code":0,"data":{"lyric":"567c4df61c8126e928c4206b1238164d00fc55d687733f4d7f9d0808a5ccb95fafd82d86e5a3be691ce6c7938220bd8871fd10c81ede12d1","trans":""}}} \ No newline at end of file diff --git a/internal/adapter/out/qq/testdata/lyric.json b/internal/adapter/out/qq/testdata/lyric.json index bcae5e5..b8aef02 100644 --- a/internal/adapter/out/qq/testdata/lyric.json +++ b/internal/adapter/out/qq/testdata/lyric.json @@ -1 +1 @@ -{"retcode":0,"code":0,"subcode":0,"lyric":"W3RpOuaZtOWkqV0KW2FyOuWRqOadsOS8pl0KWzAwOjAwLjAwXeaZtOWkqSAtIOWRqOadsOS8piAoSmF5IENob3UpCg=="} +{"code":0,"request":{"code":0,"data":{"lyric":"9f805844e44d85137f363bf13b6cbdeefd9467d25470a77f7ac8a94038f52c954933e38a06674b301004d53b7ae404892dc3dc02e0bfcaf65f779221b2038470e1d0783c9cbc166e3bece62062873db6ffd651c6e2aaf93562f3d4daee6242fb7f0d0c78ad57ffe97f9d0808a5ccb95f92dbe8bb1fb3a2d559ab888e59f7916aa63eaecfd024155f","trans":"3202b47e88d10c209776197f2e7e94bdc629e98a4fb99b02cb1c6054ae4423ea40fe03e017f80d6701714edab4b127a55082f25280299d31"}}} \ No newline at end of file diff --git a/internal/app/port/source.go b/internal/app/port/source.go index 1a2fd85..76895e4 100644 --- a/internal/app/port/source.go +++ b/internal/app/port/source.go @@ -87,7 +87,10 @@ type ( Resolved struct { Meta track.Meta Lyric string - Audio io.ReadCloser + // 逐字歌词与翻译歌词,格式同 track.Prepared 的对应字段,可为空。 + LyricWords string + LyricTrans string + Audio io.ReadCloser } ) diff --git a/internal/app/prepare/run.go b/internal/app/prepare/run.go index 2bb783a..ab83782 100644 --- a/internal/app/prepare/run.go +++ b/internal/app/prepare/run.go @@ -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}) diff --git a/internal/bootstrap/serve.go b/internal/bootstrap/serve.go index db2dacb..385e91f 100644 --- a/internal/bootstrap/serve.go +++ b/internal/bootstrap/serve.go @@ -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{ diff --git a/internal/domain/track/track.go b/internal/domain/track/track.go index 40b5c08..af1ae3f 100644 --- a/internal/domain/track/track.go +++ b/internal/domain/track/track.go @@ -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 }