diff --git a/internal/adapter/out/qq/qq.go b/internal/adapter/out/qq/qq.go index a4aed7b..d8c4da0 100644 --- a/internal/adapter/out/qq/qq.go +++ b/internal/adapter/out/qq/qq.go @@ -12,7 +12,6 @@ import ( "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" ) @@ -244,24 +243,24 @@ func (c *Client) lyric(ctx context.Context, id string) (lyricResult, error) { func parsePlayLyric(lyricHex, transHex string) (lyricResult, error) { var out lyricResult if lyricHex != "" { - plain, err := qrc.DecryptHex(lyricHex) + plain, err := decryptQRCHex(lyricHex) if err != nil { return lyricResult{}, fmt.Errorf("解密 QQ 音乐的歌词:%w", err) } - tags, lines := qrc.Parse(plain) + tags, lines := parseQRC(plain) if len(lines) == 0 { // 没有逐字数据时 LyricContent 里直接是行级 LRC。 out.LRC = text(plain) } else { - out.LRC = text(qrc.RenderLRC(tags, lines)) + out.LRC = text(renderLRCLines(tags, lines)) if hasWordTimings(lines) { - out.Words = qrc.RenderYRC(lines) + out.Words = renderYRCLines(lines) } } } if transHex != "" { // 翻译解密后是行级 LRC;翻译是增强信息,解密失败只丢翻译。 - if plain, err := qrc.DecryptHex(transHex); err == nil { + if plain, err := decryptQRCHex(transHex); err == nil { out.Trans = cleanTrans(plain) } } @@ -287,7 +286,7 @@ func cleanTrans(lrc string) string { return text(b.String()) } -func hasWordTimings(lines []qrc.Line) bool { +func hasWordTimings(lines []qrcLine) bool { for _, l := range lines { if len(l.Words) > 0 { return true diff --git a/internal/adapter/out/qq/qrc/qrc.go b/internal/adapter/out/qq/qrc.go similarity index 68% rename from internal/adapter/out/qq/qrc/qrc.go rename to internal/adapter/out/qq/qrc.go index f8f3bc9..d77dc7e 100644 --- a/internal/adapter/out/qq/qrc/qrc.go +++ b/internal/adapter/out/qq/qrc.go @@ -1,4 +1,4 @@ -package qrc +package qq import ( "fmt" @@ -8,22 +8,22 @@ import ( "strings" ) -// Word 是一段带独立时间的歌词片段,通常是单个字。毫秒,绝对于曲目起点。 -type Word struct { +// qrcWord 是一段带独立时间的歌词片段,通常是单个字。毫秒,绝对于曲目起点。 +type qrcWord struct { StartMs int DurationMs int Text string } -// Line 是一行逐字歌词。毫秒,绝对于曲目起点。 -type Line struct { +// qrcLine 是一行逐字歌词。毫秒,绝对于曲目起点。 +type qrcLine struct { StartMs int DurationMs int - Words []Word + Words []qrcWord } // Text 拼接整行文本。 -func (l Line) Text() string { +func (l qrcLine) text() string { var b strings.Builder for _, w := range l.Words { b.WriteString(w.Text) @@ -39,8 +39,8 @@ var ( ) // QRC 的时间标记跟在它所定时的文本之后;只有内容是纯数字时间的圆括号才是标记,其余按歌词原文保留。 -func parseWords(content string) []Word { - var words []Word +func parseWords(content string) []qrcWord { + var words []qrcWord start := 0 for i := 0; i < len(content); i++ { if content[i] != '(' { @@ -57,25 +57,25 @@ func parseWords(content string) []Word { 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}) + words = append(words, qrcWord{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}) + words = append(words, qrcWord{StartMs: last.StartMs + last.DurationMs, Text: tail}) } return words } // Parse 解析解密后的 QRC 文本:剥掉 XML 外壳,返回头部标签和逐字行;kana 等注音注解行被丢弃。 -func Parse(text string) (map[string]string, []Line) { +func parseQRC(text string) (map[string]string, []qrcLine) { if m := contentRe.FindStringSubmatch(text); m != nil { text = html.UnescapeString(m[1]) } tags := map[string]string{} - var lines []Line + var lines []qrcLine for _, raw := range strings.Split(text, "\n") { row := strings.TrimSpace(raw) if row == "" { @@ -89,14 +89,14 @@ func Parse(text string) (map[string]string, []Line) { if m == nil { continue } - line := Line{StartMs: atoi(m[1]), DurationMs: atoi(m[2]), Words: parseWords(m[3])} + line := qrcLine{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 { +// renderYRCLines 输出传输用的逐字格式(YRC 语法):[行起始,行时长](字起始,字时长,0)字…,毫秒为绝对时间。 +func renderYRCLines(lines []qrcLine) string { var b strings.Builder for _, l := range lines { fmt.Fprintf(&b, "[%d,%d]", l.StartMs, l.DurationMs) @@ -108,8 +108,8 @@ func RenderYRC(lines []Line) string { return b.String() } -// RenderLRC 输出行级 LRC:头部标签行在前,随后是 [分:秒.厘秒]行文本。 -func RenderLRC(tags map[string]string, lines []Line) string { +// renderLRCLines 输出行级 LRC:头部标签行在前,随后是 [分:秒.厘秒]行文本。 +func renderLRCLines(tags map[string]string, lines []qrcLine) string { var b strings.Builder for _, k := range []string{"ti", "ar", "al", "by", "offset"} { if v, ok := tags[k]; ok { @@ -117,7 +117,7 @@ func RenderLRC(tags map[string]string, lines []Line) string { } } for _, l := range lines { - fmt.Fprintf(&b, "[%02d:%05.2f]%s\n", l.StartMs/60000, float64(l.StartMs%60000)/1000, l.Text()) + fmt.Fprintf(&b, "[%02d:%05.2f]%s\n", l.StartMs/60000, float64(l.StartMs%60000)/1000, l.text()) } return b.String() } diff --git a/internal/adapter/out/qq/qrc/des.go b/internal/adapter/out/qq/qrc_des.go similarity index 95% rename from internal/adapter/out/qq/qrc/des.go rename to internal/adapter/out/qq/qrc_des.go index 1e65a9c..fb24bbe 100644 --- a/internal/adapter/out/qq/qrc/des.go +++ b/internal/adapter/out/qq/qrc_des.go @@ -1,7 +1,7 @@ -// Package qrc 解密并解析 QQ 音乐的 QRC 歌词:密文是十六进制字符串, -// 加密为 QQ 改版 DES 的三轮 EDE,解压为 zlib。改版 DES 与标准 DES 不兼容: -// 32 位字内字节小端、两个 S 盒条目被改动、PC-2 的 D 半区错位一位。 -package qrc +package qq + +// QQ 歌词的密文是十六进制字符串,加密为 QQ 改版 DES 的三轮 EDE,解压为 zlib。 +// 改版 DES 与标准 DES 不兼容:32 位字内字节小端、两个 S 盒条目被改动、PC-2 的 D 半区错位一位。 import ( "bytes" @@ -14,8 +14,8 @@ import ( var qrcKey = []byte("!@#)(*$%123ZXC!@!@#)(NHL") -// DecryptHex 解密十六进制的 QRC 密文。 -func DecryptHex(content string) (string, error) { +// decryptQRCHex 解密十六进制的 QRC 密文。 +func decryptQRCHex(content string) (string, error) { encrypted, err := hex.DecodeString(strings.TrimSpace(content)) if err != nil { return "", fmt.Errorf("QRC 密文不是十六进制:%w", err) diff --git a/internal/adapter/out/qq/qrc/sbox.go b/internal/adapter/out/qq/qrc_sbox.go similarity index 99% rename from internal/adapter/out/qq/qrc/sbox.go rename to internal/adapter/out/qq/qrc_sbox.go index 40fda6b..3c2b4ca 100644 --- a/internal/adapter/out/qq/qrc/sbox.go +++ b/internal/adapter/out/qq/qrc_sbox.go @@ -1,4 +1,4 @@ -package qrc +package qq 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}, diff --git a/internal/adapter/out/qq/qrc/qrc_test.go b/internal/adapter/out/qq/qrc_test.go similarity index 79% rename from internal/adapter/out/qq/qrc/qrc_test.go rename to internal/adapter/out/qq/qrc_test.go index 2871141..a7bc216 100644 --- a/internal/adapter/out/qq/qrc/qrc_test.go +++ b/internal/adapter/out/qq/qrc_test.go @@ -1,4 +1,4 @@ -package qrc +package qq import ( "strings" @@ -26,8 +26,8 @@ const syntheticPlain = ` ` -func TestDecryptHexGolden(t *testing.T) { - got, err := DecryptHex(syntheticHex) +func TestDecryptQRCHexGolden(t *testing.T) { + got, err := decryptQRCHex(syntheticHex) if err != nil { t.Fatalf("解密失败:%v", err) } @@ -36,20 +36,20 @@ func TestDecryptHexGolden(t *testing.T) { } } -func TestDecryptHexRejectsBadInput(t *testing.T) { - if _, err := DecryptHex("not-hex"); err == nil { +func TestDecryptQRCHexRejectsBadInput(t *testing.T) { + if _, err := decryptQRCHex("not-hex"); err == nil { t.Error("非十六进制输入没有报错") } - if _, err := DecryptHex("0011"); err == nil { + if _, err := decryptQRCHex("0011"); err == nil { t.Error("长度不是 8 的倍数没有报错") } - if _, err := DecryptHex(strings.Repeat("00", 16)); err == nil { + if _, err := decryptQRCHex(strings.Repeat("00", 16)); err == nil { t.Error("非 zlib 数据没有报错") } } func TestParseAndRender(t *testing.T) { - tags, lines := Parse(syntheticPlain) + tags, lines := parseQRC(syntheticPlain) if tags["ti"] != "合成测试歌" { t.Errorf("ti 标签 = %q", tags["ti"]) } @@ -60,22 +60,22 @@ func TestParseAndRender(t *testing.T) { 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 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: "你"}) { + if len(line.Words) != 3 || line.Words[0] != (qrcWord{StartMs: 1000, DurationMs: 500, Text: "你"}) { t.Errorf("首行逐字 = %#v", line.Words) } // 歌词原文里的全角圆括号不是时间标记,要保留在文本里。 - if text := lines[1].Text(); text != "括号(内容)" { + if text := lines[1].text(); text != "括号(内容)" { t.Errorf("含括号的行 = %q", text) } - lrc := RenderLRC(tags, lines) + lrc := renderLRCLines(tags, lines) if !strings.Contains(lrc, "[ti:合成测试歌]\n") || !strings.Contains(lrc, "[00:01.00]你好世\n") { t.Errorf("LRC 渲染缺少预期行:%.120q", lrc) } - yrc := RenderYRC(lines) + yrc := renderYRCLines(lines) if !strings.Contains(yrc, "[1000,1500](1000,500,0)你(1500,500,0)好(2000,500,0)世\n") { t.Errorf("YRC 渲染缺少预期行:%.120q", yrc) } @@ -83,7 +83,7 @@ func TestParseAndRender(t *testing.T) { // 没有逐字时间标记的内容(纯 LRC 文本)解析不出行。 func TestParsePlainLRCYieldsNoLines(t *testing.T) { - _, lines := Parse("[00:01.00]普通歌词\n[00:03.00]第二行\n") + _, lines := parseQRC("[00:01.00]普通歌词\n[00:03.00]第二行\n") if len(lines) != 0 { t.Errorf("纯 LRC 解析出 %d 行,期望 0", len(lines)) }