refactor: fold QQ lyric decryption into the qq package

This commit is contained in:
DiheChen committed 2026-11-05 03:15:11 +08:00
1 parent 75c5dad4b9
commit cce68d760e
5 files changed
+47 -48

No files matched your search

+6 -7
View File
@@ -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
@@ -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()
}
@@ -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)
@@ -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},
@@ -1,4 +1,4 @@
package qrc
package qq
import (
"strings"
@@ -26,8 +26,8 @@ const syntheticPlain = `<?xml version="1.0" encoding="utf-8"?>
</LyricInfo>
</QrcInfos>`
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))
}