feat: add translated and word-by-word lyrics for QQ and NetEase

This commit is contained in:
DiheChen committed 2026-11-04 23:50:21 +08:00
1 parent 8640da55e1
commit bec757c7a5
26 files changed
+938 -88

No files matched your search

+14 -4
View File
@@ -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 没有返回错误")
}
}
+1 -1
View File
@@ -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) {
+74 -9
View File
@@ -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)},
+12 -7
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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) {
+13 -3
View File
@@ -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)
+113
View File
@@ -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 没有返回错误")
}
}
+2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+2 -1
View File
@@ -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
View File
@@ -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 {
+71 -31
View File
@@ -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)
}
}
+180
View File
@@ -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
}
+128
View File
@@ -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
}
+90
View File
@@ -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))
}
}
+12
View File
@@ -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},
}
+1
View File
@@ -0,0 +1 @@
{"code":0,"request":{"code":0,"data":{"lyric":"05859e276a59efb79776197f2e7e94bd1641dad6de9769283c963ddbb39fee8d55e201e8dcf0642ff9b4955a54a1a83f","trans":""}}}
+1 -1
View File
@@ -1 +1 @@
{"retcode":1101,"code":1101,"subcode":1101}
{"code":0,"request":{"code":0,"data":{"lyric":"","trans":""}}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"request":{"code":0,"data":{"lyric":"567c4df61c8126e928c4206b1238164d00fc55d687733f4d7f9d0808a5ccb95fafd82d86e5a3be691ce6c7938220bd8871fd10c81ede12d1","trans":""}}}
+1 -1
View File
@@ -1 +1 @@
{"retcode":0,"code":0,"subcode":0,"lyric":"W3RpOuaZtOWkqV0KW2FyOuWRqOadsOS8pl0KWzAwOjAwLjAwXeaZtOWkqSAtIOWRqOadsOS8piAoSmF5IENob3UpCg=="}
{"code":0,"request":{"code":0,"data":{"lyric":"9f805844e44d85137f363bf13b6cbdeefd9467d25470a77f7ac8a94038f52c954933e38a06674b301004d53b7ae404892dc3dc02e0bfcaf65f779221b2038470e1d0783c9cbc166e3bece62062873db6ffd651c6e2aaf93562f3d4daee6242fb7f0d0c78ad57ffe97f9d0808a5ccb95f92dbe8bb1fb3a2d559ab888e59f7916aa63eaecfd024155f","trans":"3202b47e88d10c209776197f2e7e94bdc629e98a4fb99b02cb1c6054ae4423ea40fe03e017f80d6701714edab4b127a55082f25280299d31"}}}
+4 -1
View File
@@ -87,7 +87,10 @@ type (
Resolved struct {
Meta track.Meta
Lyric string
Audio io.ReadCloser
// 逐字歌词与翻译歌词,格式同 track.Prepared 的对应字段,可为空。
LyricWords string
LyricTrans string
Audio io.ReadCloser
}
)
+1
View File
@@ -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})
+3 -2
View File
@@ -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{
+4
View File
@@ -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
}