diff --git a/README.md b/README.md index cc98570..21b7933 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ ## 功能 - 搜索歌曲并显示歌手、专辑和时长。 -- 导出原文 `.lrc`、翻译 `.trans.lrc` 或无时间轴 `.txt`。 +- 完整歌词预览,可按歌曲实际内容选择仅原文、附带翻译、附带“音”,或同时附带两者。 +- 导出原文 LRC/TXT;有真实翻译时才提供翻译 LRC/TXT,有罗马音、韩语音译或方言注音时才提供“音” LRC/TXT。 +- 歌词来自 QQ 音乐 `GetPlayLyricInfo` 返回的原文、翻译与 `roma` 字段;本站不进行机器翻译,也不自行生成注音。 - 网页版只转发到源码中固定的 QQ 音乐接口,不保存搜索词、歌词或历史。 - 可选本机版只监听随机的 `127.0.0.1` 端口。 diff --git a/docker-compose.yml b/docker-compose.yml index 5ff5a01..bb31800 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . args: - VERSION: v2026.08.25-r5 + VERSION: v2026.08.26-r6 restart: unless-stopped ports: - "127.0.0.1:8083:8080" diff --git a/qq.go b/qq.go index a3e53dc..3c96036 100644 --- a/qq.go +++ b/qq.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -13,9 +14,10 @@ import ( ) const ( - searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp" - lyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" - maxUpstreamBytes = 4 << 20 + searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp" + musicuEndpoint = "https://u.y.qq.com/cgi-bin/musicu.fcg" + legacyLyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" + maxUpstreamBytes = 4 << 20 ) type song struct { @@ -29,6 +31,7 @@ type song struct { type lyricResult struct { Lyric string `json:"lyric"` Trans string `json:"trans"` + Roma string `json:"roma"` Code int `json:"code"` } @@ -40,6 +43,7 @@ type qqAPI interface { type qqClient struct { httpClient *http.Client searchEndpoint string + musicuEndpoint string lyricsEndpoint string } @@ -57,7 +61,8 @@ func newQQClient() *qqClient { }, }, searchEndpoint: searchEndpoint, - lyricsEndpoint: lyricsEndpoint, + musicuEndpoint: musicuEndpoint, + lyricsEndpoint: legacyLyricsEndpoint, } } @@ -112,6 +117,54 @@ func (c *qqClient) Search(keyword string, limit int) ([]song, error) { } func (c *qqClient) Lyrics(mid string) (lyricResult, error) { + result, err := c.musicuLyrics(mid) + if err == nil { + return result, nil + } + return c.legacyLyrics(mid) +} + +func (c *qqClient) musicuLyrics(mid string) (lyricResult, error) { + payload := map[string]any{ + "comm": map[string]any{"ct": 24, "cv": 0, "format": "json", "platform": "yqq.json"}, + "req_1": map[string]any{ + "module": "music.musichallSong.PlayLyricInfo", + "method": "GetPlayLyricInfo", + "param": map[string]any{ + "songMID": mid, "songID": 0, "crypt": 0, "qrc": 0, "trans": 1, "roma": 1, + }, + }, + } + var response struct { + Code int `json:"code"` + Request struct { + Code int `json:"code"` + Data struct { + Lyric string `json:"lyric"` + Trans string `json:"trans"` + Roma string `json:"roma"` + } `json:"data"` + } `json:"req_1"` + } + if err := c.postJSON(c.musicuEndpoint, payload, &response); err != nil { + return lyricResult{}, err + } + if response.Code != 0 || response.Request.Code != 0 { + return lyricResult{}, errors.New("QQ Music lyrics request failed") + } + lyric, err := decodeLyricBase64(response.Request.Data.Lyric) + if err != nil || strings.TrimSpace(lyric) == "" { + return lyricResult{}, errors.New("lyrics are not available for this track") + } + trans, _ := decodeLyricBase64(response.Request.Data.Trans) + roma := "" + if response.Request.Data.Roma != "" { + roma, _ = decryptQRCLyric(response.Request.Data.Roma) + } + return lyricResult{Lyric: lyric, Trans: trans, Roma: roma, Code: 0}, nil +} + +func (c *qqClient) legacyLyrics(mid string) (lyricResult, error) { query := url.Values{ "format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"}, } @@ -125,11 +178,42 @@ func (c *qqClient) Lyrics(mid string) (lyricResult, error) { return result, nil } +func decodeLyricBase64(value string) (string, error) { + if value == "" { + return "", nil + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", errors.New("QQ Music returned invalid lyric encoding") + } + if len(decoded) > maxUpstreamBytes { + return "", errors.New("decoded lyrics exceeded the safety limit") + } + return string(decoded), nil +} + +func (c *qqClient) postJSON(endpoint string, payload, destination any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + return c.doJSON(request, destination) +} + func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error { request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil) if err != nil { return err } + return c.doJSON(request, destination) +} + +func (c *qqClient) doJSON(request *http.Request, destination any) error { request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36") request.Header.Set("Referer", "https://y.qq.com/") request.Header.Set("Origin", "https://y.qq.com") diff --git a/qq_test.go b/qq_test.go index 28a3cfb..1821b4c 100644 --- a/qq_test.go +++ b/qq_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "encoding/base64" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -15,6 +17,49 @@ func TestUnwrapJSONP(t *testing.T) { } } +func TestQQClientMusicuLyricsRequestsTranslationAndPhonetics(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.Header.Get("Content-Type")) + } + var body struct { + Request struct { + Module string `json:"module"` + Method string `json:"method"` + Param struct { + MID string `json:"songMID"` + Trans int `json:"trans"` + Roma int `json:"roma"` + QRC int `json:"qrc"` + } `json:"param"` + } `json:"req_1"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Request.Module != "music.musichallSong.PlayLyricInfo" || body.Request.Method != "GetPlayLyricInfo" || body.Request.Param.MID != "0004jPDk2eB2dt" { + t.Fatalf("unexpected request body: %#v", body) + } + if body.Request.Param.Trans != 1 || body.Request.Param.Roma != 1 || body.Request.Param.QRC != 0 { + t.Fatalf("required lyric variants were not requested: %#v", body.Request.Param) + } + lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]原文")) + trans := base64.StdEncoding.EncodeToString([]byte("[00:01.00]translation")) + _, _ = w.Write([]byte(`{"code":0,"req_1":{"code":0,"data":{"lyric":"` + lyric + `","trans":"` + trans + `"}}}`)) + })) + defer server.Close() + + client := newQQClient() + client.musicuEndpoint = server.URL + result, err := client.musicuLyrics("0004jPDk2eB2dt") + if err != nil { + t.Fatal(err) + } + if result.Lyric != "[00:01.00]原文" || result.Trans != "[00:01.00]translation" { + t.Fatalf("unexpected lyrics: %#v", result) + } +} + func TestQQClientSearchUsesFixedShape(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Origin") != "https://y.qq.com" || r.Header.Get("Referer") != "https://y.qq.com/" { @@ -44,8 +89,8 @@ func TestQQClientRejectsOversizedResponse(t *testing.T) { })) defer server.Close() client := newQQClient() - client.lyricsEndpoint = server.URL - _, err := client.Lyrics("0004jPDk2eB2dt") + client.musicuEndpoint = server.URL + _, err := client.musicuLyrics("0004jPDk2eB2dt") if err == nil || !strings.Contains(err.Error(), "safety limit") { t.Fatalf("expected response limit error, got %v", err) } diff --git a/qrc.go b/qrc.go new file mode 100644 index 0000000..8d5f359 --- /dev/null +++ b/qrc.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// The QRC transform and keys below follow the community-provided +// qq_lyric_tables.py implementation supplied with the original script. +var ( + qrcKey1 = [8]byte{'!', '@', '#', ')', '(', '*', '$', '%'} + qrcKey2 = [8]byte{'1', '2', '3', 'Z', 'X', 'C', '!', '@'} + qrcKey3 = [8]byte{'!', '@', '#', ')', '(', 'N', 'H', 'L'} +) + +const maxQRCBytes = 4 << 20 + +var ( + qrcWordTimingPattern = regexp.MustCompile(`\(\d+,\d+\)`) + qrcLinePattern = regexp.MustCompile(`^\[(\d+),(\d+)\](.*)$`) + qrcMetadataPattern = regexp.MustCompile(`^\[(ti|ar|al|by|offset|kana|language|total):`) +) + +func decryptQRCLyric(encoded string) (string, error) { + encrypted, err := hex.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return "", errors.New("QQ Music returned invalid phonetic lyrics") + } + if len(encrypted) == 0 || len(encrypted)%8 != 0 || len(encrypted) > maxUpstreamBytes { + return "", errors.New("QQ Music returned invalid phonetic lyric length") + } + decrypted := append([]byte(nil), encrypted...) + transformQRCBlocks(decrypted, newQRCDes(qrcKey3, true)) + transformQRCBlocks(decrypted, newQRCDes(qrcKey2, false)) + transformQRCBlocks(decrypted, newQRCDes(qrcKey1, true)) + + reader, err := zlib.NewReader(bytes.NewReader(decrypted)) + if err != nil { + return "", errors.New("QQ Music returned invalid compressed phonetic lyrics") + } + decompressed, readErr := io.ReadAll(io.LimitReader(reader, maxQRCBytes+1)) + closeErr := reader.Close() + if readErr != nil || closeErr != nil { + return "", errors.New("could not decompress phonetic lyrics") + } + if len(decompressed) > maxQRCBytes { + return "", errors.New("phonetic lyrics exceeded the safety limit") + } + return qrcXMLToLRC(decompressed) +} + +func qrcXMLToLRC(document []byte) (string, error) { + decoder := xml.NewDecoder(bytes.NewReader(document)) + content := "" + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", errors.New("QQ Music returned invalid phonetic lyric XML") + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + for _, attribute := range start.Attr { + if attribute.Name.Local == "LyricContent" { + content = attribute.Value + break + } + } + if content != "" { + break + } + } + if content == "" { + return "", errors.New("phonetic lyrics did not contain lyric content") + } + + lines := make([]string, 0, strings.Count(content, "\n")+1) + for _, rawLine := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + if qrcMetadataPattern.MatchString(line) { + lines = append(lines, line) + continue + } + match := qrcLinePattern.FindStringSubmatch(line) + if match == nil { + lines = append(lines, line) + continue + } + start, err := strconv.Atoi(match[1]) + if err != nil || start < 0 { + continue + } + lyric := strings.TrimSpace(qrcWordTimingPattern.ReplaceAllString(match[3], "")) + if lyric == "" { + continue + } + lines = append(lines, fmt.Sprintf("[%02d:%02d.%03d]%s", start/60000, start%60000/1000, start%1000, lyric)) + } + if len(lines) == 0 { + return "", errors.New("phonetic lyrics were empty") + } + return strings.Join(lines, "\n"), nil +} + +type qrcDes struct { + subkeys [16]uint64 +} + +func newQRCDes(key [8]byte, decrypt bool) qrcDes { + parameter := qrcMap64(binary.LittleEndian.Uint64(key[:]), qrcKeyPermutation[:]) + c, d := uint32(parameter), uint32(parameter>>32) + var result qrcDes + for round, shift := range qrcRoundShifts { + c = c<>(28-shift))&0xfffffff0 + d = d<>(28-shift))&0xfffffff0 + subkey := qrcMap64(uint64(d)<<32|uint64(c), qrcKeyCompression[:]) + if decrypt { + result.subkeys[15-round] = subkey + } else { + result.subkeys[round] = subkey + } + } + return result +} + +func (d qrcDes) transform(value uint64) uint64 { + state := qrcMap64(value, qrcInitialPermutation[:]) + for _, key := range d.subkeys { + right := uint32(state >> 32) + left := uint32(state) + expanded := qrcMap64(uint64(right)<<32|uint64(right), qrcKeyExpansion[:]) ^ key + sbox := uint32(0) + for index, shift := range qrcSBoxShifts { + sbox = sbox<<4 | uint32(qrcSBoxes[index][expanded>>shift&0x3f]) + } + next := qrcMap32(sbox, qrcPBox[:]) ^ left + state = uint64(next)<<32 | uint64(right) + } + state = state>>32 | state<<32 + return qrcMap64(state, qrcFinalPermutation[:]) +} + +func transformQRCBlocks(data []byte, cipher qrcDes) { + for offset := 0; offset < len(data); offset += 8 { + binary.LittleEndian.PutUint64(data[offset:offset+8], cipher.transform(binary.LittleEndian.Uint64(data[offset:offset+8]))) + } +} + +func qrcBitMask(index uint8) uint64 { + if index < 32 { + return uint64(1) << (31 - index) + } + return uint64(1) << (63 - index + 32) +} + +func qrcMapBit(result, source uint64, check, set uint8) uint64 { + if source&qrcBitMask(check) != 0 { + result |= qrcBitMask(set) + } + return result +} + +func qrcMap64(source uint64, table []uint8) uint64 { + half := len(table) / 2 + var low, high uint64 + for index := 0; index < half; index++ { + low = qrcMapBit(low, source, table[index], uint8(index)) + high = qrcMapBit(high, source, table[index+half], uint8(index)) + } + return uint64(uint32(high))<<32 | uint64(uint32(low)) +} + +func qrcMap32(source uint32, table []uint8) uint32 { + var result uint64 + for index, check := range table { + result = qrcMapBit(result, uint64(source), check, uint8(index)) + } + return uint32(result) +} + +var qrcRoundShifts = [16]uint8{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1} +var qrcSBoxShifts = [8]uint8{26, 20, 14, 8, 58, 52, 46, 40} + +var qrcInitialPermutation = [64]uint8{ + 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7, + 56, 48, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, +} + +var qrcFinalPermutation = [64]uint8{ + 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, + 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25, 32, 0, 40, 8, 48, 16, 56, 24, +} + +var qrcKeyPermutation = [56]uint8{ + 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, + 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, +} + +var qrcKeyCompression = [48]uint8{ + 13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, + 45, 56, 35, 41, 51, 59, 34, 44, 55, 49, 37, 52, 48, 53, 43, 60, 38, 57, 50, 46, 54, 40, 33, 36, +} + +var qrcKeyExpansion = [48]uint8{ + 31, 0, 1, 2, 3, 4, 3, 4, 5, 6, 7, 8, 7, 8, 9, 10, 11, 12, 11, 12, 13, 14, 15, 16, 15, 16, 17, 18, 19, 20, 19, 20, 21, 22, 23, 24, + 23, 24, 25, 26, 27, 28, 27, 28, 29, 30, 31, 0, +} + +var qrcPBox = [32]uint8{15, 6, 19, 20, 28, 11, 27, 16, 0, 14, 22, 25, 4, 17, 30, 9, 1, 7, 23, 13, 31, 26, 2, 8, 18, 12, 29, 5, 21, 10, 3, 24} + +var qrcSBoxes = [8][64]uint8{ + {14, 0, 4, 15, 13, 7, 1, 4, 2, 14, 15, 2, 11, 13, 8, 1, 3, 10, 10, 6, 6, 12, 12, 11, 5, 9, 9, 5, 0, 3, 7, 8, 4, 15, 1, 12, 14, 8, 8, 2, 13, 4, 6, 9, 2, 1, 11, 7, 15, 5, 12, 11, 9, 3, 7, 14, 3, 10, 10, 0, 5, 6, 0, 13}, + {15, 3, 1, 13, 8, 4, 14, 7, 6, 15, 11, 2, 3, 8, 4, 15, 9, 12, 7, 0, 2, 1, 13, 10, 12, 6, 0, 9, 5, 11, 10, 5, 0, 13, 14, 8, 7, 10, 11, 1, 10, 3, 4, 15, 13, 4, 1, 2, 5, 11, 8, 6, 12, 7, 6, 12, 9, 0, 3, 5, 2, 14, 15, 9}, + {10, 13, 0, 7, 9, 0, 14, 9, 6, 3, 3, 4, 15, 6, 5, 10, 1, 2, 13, 8, 12, 5, 7, 14, 11, 12, 4, 11, 2, 15, 8, 1, 13, 1, 6, 10, 4, 13, 9, 0, 8, 6, 15, 9, 3, 8, 0, 7, 11, 4, 1, 15, 2, 14, 12, 3, 5, 11, 10, 5, 14, 2, 7, 12}, + {7, 13, 13, 8, 14, 11, 3, 5, 0, 6, 6, 15, 9, 0, 10, 3, 1, 4, 2, 7, 8, 2, 5, 12, 11, 1, 12, 10, 4, 14, 15, 9, 10, 3, 6, 15, 9, 0, 0, 6, 12, 10, 11, 10, 7, 13, 13, 8, 15, 9, 1, 4, 3, 5, 14, 11, 5, 12, 2, 7, 8, 2, 4, 14}, + {2, 14, 12, 11, 4, 2, 1, 12, 7, 4, 10, 7, 11, 13, 6, 1, 8, 5, 5, 0, 3, 15, 15, 10, 13, 3, 0, 9, 14, 8, 9, 6, 4, 11, 2, 8, 1, 12, 11, 7, 10, 1, 13, 14, 7, 2, 8, 13, 15, 6, 9, 15, 12, 0, 5, 9, 6, 10, 3, 4, 0, 5, 14, 3}, + {12, 10, 1, 15, 10, 4, 15, 2, 9, 7, 2, 12, 6, 9, 8, 5, 0, 6, 13, 1, 3, 13, 4, 14, 14, 0, 7, 11, 5, 3, 11, 8, 9, 4, 14, 3, 15, 2, 5, 12, 2, 9, 8, 5, 12, 15, 3, 10, 7, 11, 0, 14, 4, 1, 10, 7, 1, 6, 13, 0, 11, 8, 6, 13}, + {4, 13, 11, 0, 2, 11, 14, 7, 15, 4, 0, 9, 8, 1, 13, 10, 3, 14, 12, 3, 9, 5, 7, 12, 5, 2, 10, 15, 6, 8, 1, 6, 1, 6, 4, 11, 11, 13, 13, 8, 12, 1, 3, 4, 7, 10, 14, 7, 10, 9, 15, 5, 6, 0, 8, 15, 0, 14, 5, 2, 9, 3, 2, 12}, + {13, 1, 2, 15, 8, 13, 4, 8, 6, 10, 15, 3, 11, 7, 1, 4, 10, 12, 9, 5, 3, 6, 14, 11, 5, 0, 0, 14, 12, 9, 7, 2, 7, 2, 11, 1, 4, 14, 1, 7, 9, 4, 12, 10, 14, 8, 2, 13, 0, 15, 6, 12, 10, 9, 13, 0, 15, 3, 3, 5, 5, 6, 8, 11}, +} diff --git a/qrc_test.go b/qrc_test.go new file mode 100644 index 0000000..a8de6fc --- /dev/null +++ b/qrc_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "bytes" + "compress/zlib" + "encoding/hex" + "strings" + "testing" +) + +func TestQRCModifiedDESVectors(t *testing.T) { + input := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6} + expected := []byte{ + 0xfd, 0x0e, 0x64, 0x06, 0x65, 0xbe, 0x74, 0x13, + 0x77, 0x63, 0x3b, 0x02, 0x45, 0x4e, 0x70, 0x7a, + } + key := [8]byte{'T', 'E', 'S', 'T', '!', 'K', 'E', 'Y'} + transformQRCBlocks(input, newQRCDes(key, true)) + if !bytes.Equal(input, expected) { + t.Fatalf("unexpected decrypt vector: %x", input) + } + transformQRCBlocks(input, newQRCDes(key, false)) + if !bytes.Equal(input, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}) { + t.Fatalf("unexpected encrypt vector: %x", input) + } +} + +func TestDecryptQRCAndConvertXML(t *testing.T) { + document := []byte(``) + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + if _, err := writer.Write(document); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + encrypted := append([]byte(nil), compressed.Bytes()...) + if remainder := len(encrypted) % 8; remainder != 0 { + encrypted = append(encrypted, make([]byte, 8-remainder)...) + } + transformQRCBlocks(encrypted, newQRCDes(qrcKey1, false)) + transformQRCBlocks(encrypted, newQRCDes(qrcKey2, true)) + transformQRCBlocks(encrypted, newQRCDes(qrcKey3, false)) + + result, err := decryptQRCLyric(hex.EncodeToString(encrypted)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result, "[00:01.498]a i") || !strings.Contains(result, "[00:03.849]完整歌词") { + t.Fatalf("unexpected converted lyrics: %q", result) + } +} + +func TestDecryptQRCRejectsInvalidInput(t *testing.T) { + if _, err := decryptQRCLyric("not-hex"); err == nil { + t.Fatal("expected invalid hex to be rejected") + } + if _, err := decryptQRCLyric("00"); err == nil { + t.Fatal("expected invalid block length to be rejected") + } +} diff --git a/server_test.go b/server_test.go index 3513ba0..59f5264 100644 --- a/server_test.go +++ b/server_test.go @@ -15,7 +15,7 @@ func (fakeQQ) Search(keyword string, limit int) ([]song, error) { return []song{{Name: keyword, MID: "0004jPDk2eB2dt", Singer: "Tester", Interval: limit}}, nil } func (fakeQQ) Lyrics(string) (lyricResult, error) { - return lyricResult{Code: 0, Lyric: "[00:01.00]line"}, nil + return lyricResult{Code: 0, Lyric: "[00:01.00]line", Trans: "[00:01.00]翻译", Roma: "[00:01.00]sound"}, nil } func testApp() (*localApp, http.Handler, fs.FS) { @@ -103,3 +103,16 @@ func TestRejectsInvalidMID(t *testing.T) { t.Fatalf("expected 400, got %d", w.Code) } } + +func TestLyricsReturnsAvailableTranslationAndPhonetics(t *testing.T) { + _, handler, _ := testApp() + r := request(http.MethodPost, "http://127.0.0.1:18765/api/lyrics", `{"mid":"0004jPDk2eB2dt"}`) + r.AddCookie(validSessionCookie()) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "http://127.0.0.1:18765") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"trans":"[00:01.00]翻译"`) || !strings.Contains(w.Body.String(), `"roma":"[00:01.00]sound"`) { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/web/app.html b/web/app.html index 419bb83..f25e147 100644 --- a/web/app.html +++ b/web/app.html @@ -3,7 +3,7 @@ - + LRC Local @@ -14,7 +14,7 @@ 源码 -

搜索歌曲并下载 LRC、翻译 LRC 或纯文本歌词。

+

搜索、完整预览并下载原文、翻译或“音”歌词。翻译与音歌词只在 QQ 音乐提供对应内容时出现。

正在确认运行模式…

diff --git a/web/app.js b/web/app.js index cad8590..0c6bd41 100644 --- a/web/app.js +++ b/web/app.js @@ -7,9 +7,10 @@ const localDownloads = document.querySelector('#local-downloads'); const lyricCache = new Map(); function text(value) { return document.createTextNode(value ?? ''); } +function hasText(value) { return typeof value === 'string' && value.trim().length > 0; } function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; } function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; } -function plainText(lrc) { return lrc.split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); } +function plainText(lrc) { return (lrc || '').split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); } async function api(path, payload) { const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); @@ -36,56 +37,249 @@ async function loadInfo() { function download(name, body, type = 'text/plain;charset=utf-8') { const url = URL.createObjectURL(new Blob([body], {type})); const anchor = document.createElement('a'); - anchor.href = url; anchor.download = name; anchor.hidden = true; - document.body.append(anchor); anchor.click(); anchor.remove(); + anchor.href = url; + anchor.download = name; + anchor.hidden = true; + document.body.append(anchor); + anchor.click(); + anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } -async function fetchAndSave(song, kind, button) { - button.disabled = true; - notice.className = 'notice'; notice.textContent = `正在读取《${song.name}》…`; - try { - if (!lyricCache.has(song.mid)) lyricCache.set(song.mid, api('/api/lyrics', {mid: song.mid})); - const data = await lyricCache.get(song.mid).catch(error => { lyricCache.delete(song.mid); throw error; }); - const base = safeName(`${song.name} - ${song.singer}`); - if (kind === 'lrc') download(`${base}.lrc`, data.lyric); - if (kind === 'txt') download(`${base}.txt`, plainText(data.lyric)); - if (kind === 'trans') { - if (!data.trans) throw new Error('这首歌没有翻译歌词'); - download(`${base}.trans.lrc`, data.trans); +function parseLRC(lrc) { + const lines = []; + for (const raw of (lrc || '').split(/\r?\n/)) { + const stamps = [...raw.matchAll(/\[(\d+):(\d+)(?:[.:](\d{1,3}))?\]/g)]; + if (!stamps.length) continue; + const content = raw.replace(/\[[^\]]*\]/g, '').trim(); + if (!content) continue; + for (const stamp of stamps) { + const fraction = stamp[3] || '0'; + const milliseconds = Number(fraction.padEnd(3, '0').slice(0, 3)); + lines.push({time: Number(stamp[1]) * 60000 + Number(stamp[2]) * 1000 + milliseconds, content}); } - notice.textContent = '文件已在浏览器中生成。'; - } catch (error) { - notice.className = 'notice error'; notice.textContent = error.message; - } finally { button.disabled = false; } + } + return lines.sort((a, b) => a.time - b.time); +} + +function timestamp(milliseconds) { + const minutes = Math.floor(milliseconds / 60000); + const seconds = Math.floor(milliseconds % 60000 / 1000); + const millis = milliseconds % 1000; + return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}]`; +} + +function alignTrack(base, extra) { + const aligned = new Array(base.length).fill(''); + let cursor = 0; + for (let index = 0; index < base.length && cursor < extra.length; index += 1) { + while (cursor + 1 < extra.length && Math.abs(extra[cursor + 1].time - base[index].time) <= Math.abs(extra[cursor].time - base[index].time)) cursor += 1; + if (Math.abs(extra[cursor].time - base[index].time) <= 1800) { + aligned[index] = extra[cursor].content; + cursor += 1; + } + } + return aligned; +} + +function previewText(data, mode) { + const original = parseLRC(data.lyric); + if (!original.length) return data.lyric || ''; + const translations = mode.includes('trans') ? alignTrack(original, parseLRC(data.trans)) : []; + const phonetics = mode.includes('roma') ? alignTrack(original, parseLRC(data.roma)) : []; + const output = []; + original.forEach((line, index) => { + output.push(`${timestamp(line.time)}${line.content}`); + if (translations[index]) output.push(` 译:${translations[index]}`); + if (phonetics[index]) output.push(` 音:${phonetics[index]}`); + }); + return output.join('\n'); +} + +function cachedLyrics(song) { + if (!lyricCache.has(song.mid)) { + const request = api('/api/lyrics', {mid: song.mid}).catch(error => { + lyricCache.delete(song.mid); + throw error; + }); + lyricCache.set(song.mid, request); + } + return lyricCache.get(song.mid); +} + +function actionButton(label, primary, handler) { + const button = document.createElement('button'); + button.type = 'button'; + if (primary) button.className = 'primary'; + button.append(text(label)); + button.addEventListener('click', handler); + return button; +} + +function saveLyrics(song, data, kind) { + const base = safeName(`${song.name} - ${song.singer}`); + const variants = { + lrc: [`${base}.lrc`, data.lyric], + txt: [`${base}.txt`, plainText(data.lyric)], + transLRC: [`${base}.trans.lrc`, data.trans], + transTXT: [`${base}.trans.txt`, plainText(data.trans)], + romaLRC: [`${base}.sound.lrc`, data.roma], + romaTXT: [`${base}.sound.txt`, plainText(data.roma)] + }; + const selected = variants[kind]; + if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词'); + download(selected[0], selected[1]); +} + +function populatePreview(select, output, data) { + const current = select.value; + select.replaceChildren(); + const choices = [['original', '仅原文']]; + if (hasText(data.trans)) choices.push(['trans', '原文 + 翻译']); + if (hasText(data.roma)) choices.push(['roma', '原文 + 音']); + if (hasText(data.trans) && hasText(data.roma)) choices.push(['trans-roma', '原文 + 翻译 + 音']); + choices.forEach(([value, label]) => { + const option = document.createElement('option'); + option.value = value; + option.append(text(label)); + select.append(option); + }); + if ([...select.options].some(option => option.value === current)) select.value = current; + output.textContent = previewText(data, select.value); +} + +function renderSongActions(song, actions, data) { + actions.replaceChildren(); + const definitions = [['lrc', '下载 LRC', true], ['txt', '原文 TXT', false]]; + if (hasText(data.trans)) definitions.push(['transLRC', '翻译 LRC', false], ['transTXT', '翻译 TXT', false]); + if (hasText(data.roma)) definitions.push(['romaLRC', '音 LRC', false], ['romaTXT', '音 TXT', false]); + definitions.forEach(([kind, label, primary]) => { + actions.append(actionButton(label, primary, () => { + try { + saveLyrics(song, data, kind); + notice.className = 'notice'; + notice.textContent = '文件已在浏览器中生成。'; + } catch (error) { + notice.className = 'notice error'; + notice.textContent = error.message; + } + })); + }); } function renderSongs(songs) { results.replaceChildren(); songs.forEach((song, index) => { - const article = document.createElement('article'); article.className = 'song'; + const article = document.createElement('article'); + article.className = 'song'; + const top = document.createElement('div'); + top.className = 'song-top'; const info = document.createElement('div'); - const title = document.createElement('h2'); title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`)); - const meta = document.createElement('div'); meta.className = 'meta'; + info.className = 'song-info'; + const title = document.createElement('h2'); + title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`)); + const meta = document.createElement('div'); + meta.className = 'meta'; meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · '))); info.append(title, meta); - const actions = document.createElement('div'); actions.className = 'actions'; - [['lrc','下载 LRC','primary'],['txt','纯文本'],['trans','翻译 LRC']].forEach(([kind,label,klass]) => { - const button = document.createElement('button'); button.type = 'button'; button.className = klass || ''; button.append(text(label)); - button.addEventListener('click', () => fetchAndSave(song, kind, button)); actions.append(button); + + const actions = document.createElement('div'); + actions.className = 'actions'; + const details = document.createElement('details'); + details.className = 'preview'; + const summary = document.createElement('summary'); + summary.append(text('完整歌词预览')); + const previewBody = document.createElement('div'); + previewBody.className = 'preview-body'; + const previewStatus = document.createElement('p'); + previewStatus.className = 'preview-status'; + previewStatus.append(text('展开后读取歌词。')); + const controls = document.createElement('label'); + controls.className = 'preview-controls'; + controls.hidden = true; + controls.append(text('预览内容 ')); + const select = document.createElement('select'); + controls.append(select); + const output = document.createElement('pre'); + output.className = 'lyric-preview'; + output.hidden = true; + previewBody.append(previewStatus, controls, output); + details.append(summary, previewBody); + + let loadedData = null; + const load = async trigger => { + if (loadedData) return loadedData; + if (trigger) trigger.disabled = true; + previewStatus.textContent = `正在读取《${song.name}》…`; + notice.className = 'notice'; + notice.textContent = `正在读取《${song.name}》…`; + try { + loadedData = await cachedLyrics(song); + renderSongActions(song, actions, loadedData); + populatePreview(select, output, loadedData); + controls.hidden = false; + output.hidden = false; + previewStatus.hidden = true; + notice.textContent = '歌词已读取;翻译和“音”按钮只在存在对应内容时显示。'; + return loadedData; + } catch (error) { + previewStatus.hidden = false; + previewStatus.textContent = error.message; + notice.className = 'notice error'; + notice.textContent = error.message; + if (details.open) details.open = false; + throw error; + } finally { + if (trigger) trigger.disabled = false; + } + }; + + const initialLRC = actionButton('下载 LRC', true, async () => { + try { + const data = await load(initialLRC); + saveLyrics(song, data, 'lrc'); + notice.textContent = '文件已在浏览器中生成。'; + } catch { /* load already reports the error */ } }); - article.append(info, actions); results.append(article); + const initialTXT = actionButton('原文 TXT', false, async () => { + try { + const data = await load(initialTXT); + saveLyrics(song, data, 'txt'); + notice.textContent = '文件已在浏览器中生成。'; + } catch { /* load already reports the error */ } + }); + actions.append(initialLRC, initialTXT); + details.addEventListener('toggle', () => { + if (details.open && !loadedData) load().catch(() => {}); + }); + select.addEventListener('change', () => { + if (loadedData) output.textContent = previewText(loadedData, select.value); + }); + + top.append(info, actions); + article.append(top, details); + results.append(article); }); } form.addEventListener('submit', async event => { - event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true; - notice.className = 'notice'; notice.textContent = '正在搜索…'; results.replaceChildren(); lyricCache.clear(); + event.preventDefault(); + const submit = form.querySelector('button'); + submit.disabled = true; + notice.className = 'notice'; + notice.textContent = '正在搜索…'; + results.replaceChildren(); + lyricCache.clear(); try { const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15}); - renderSongs(data.songs || []); notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。` : '没有找到相关歌曲。'; - } catch (error) { notice.className = 'notice error'; notice.textContent = error.message; } - finally { submit.disabled = false; } + renderSongs(data.songs || []); + notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。展开预览或下载时才会读取歌词。` : '没有找到相关歌曲。'; + } catch (error) { + notice.className = 'notice error'; + notice.textContent = error.message; + } finally { + submit.disabled = false; + } }); loadInfo(); diff --git a/web/styles.css b/web/styles.css index 4e22638..6488ead 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1 +1 @@ -:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(820px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:650px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}} +:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(900px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus,select:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px;min-width:0}.song-top{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center}.song-info{min-width:0}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.preview{margin-top:12px;border-top:1px solid #eaecf0;padding-top:10px}.preview summary{color:#155eef;cursor:pointer;font-size:.9rem;width:max-content}.preview-body{padding-top:10px}.preview-status{color:#667085;font-size:.86rem;margin:0}.preview-controls{font-size:.86rem;margin:0 0 8px}.preview-controls select{border:1px solid #b8c0cc;border-radius:6px;background:#fff;color:#1f2937;padding:6px 28px 6px 8px;font:inherit}.lyric-preview{width:100%;max-height:min(62vh,720px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;border:1px solid #dfe3e8;border-radius:7px;background:#f8fafc;color:#344054;font:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;font-size:.82rem;line-height:1.65}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:700px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song-top{grid-template-columns:1fr}.actions{justify-content:flex-start}.actions button{flex:1 1 auto}.preview summary{width:auto}.lyric-preview{max-height:58vh;padding:10px;font-size:.78rem}}