diff --git a/go.mod b/go.mod index da476e5..5d07b31 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/gorilla/mux v1.8.1 + github.com/jixunmoe-go/qrc v0.0.0-20230917162828-866e996416b0 github.com/joho/godotenv v1.5.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/rs/cors v1.11.0 diff --git a/go.sum b/go.sum index 9129c36..fe4cd94 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jixunmoe-go/qrc v0.0.0-20230917162828-866e996416b0 h1:XbKYQezv+JSdPBJE16KHzD2afrJB7tkc3wsJiVk4ilY= +github.com/jixunmoe-go/qrc v0.0.0-20230917162828-866e996416b0/go.mod h1:krzGKG44lKGayicX0UaQ/i53oHznpk4DmrBw2LnhpfI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= diff --git a/handlers.go b/handlers.go index acbc69e..5a027c4 100644 --- a/handlers.go +++ b/handlers.go @@ -17,6 +17,7 @@ import ( // Import providers to trigger their init() registration _ "lyrics-api-go/services/providers/kugou" _ "lyrics-api-go/services/providers/legacy" + _ "lyrics-api-go/services/providers/qq" ttml "lyrics-api-go/services/providers/ttml" "github.com/gorilla/mux" diff --git a/routes.go b/routes.go index 1229688..0b9183a 100644 --- a/routes.go +++ b/routes.go @@ -15,6 +15,7 @@ func setupRoutes(router *mux.Router) { // Provider-specific endpoints - return {"lyrics": ..., "provider": ...} router.HandleFunc("/ttml/getLyrics", getLyricsWithProvider("ttml")) router.HandleFunc("/kugou/getLyrics", getLyricsWithProvider("kugou")) + router.HandleFunc("/qq/getLyrics", getLyricsWithProvider("qq")) router.HandleFunc("/legacy/getLyrics", getLyricsWithProvider("legacy")) // Cache management endpoints diff --git a/services/providers/qq/client.go b/services/providers/qq/client.go new file mode 100644 index 0000000..48ac8b7 --- /dev/null +++ b/services/providers/qq/client.go @@ -0,0 +1,407 @@ +package qq + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "lyrics-api-go/logcolors" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/jixunmoe-go/qrc" + log "github.com/sirupsen/logrus" +) + +const ( + apiURL = "https://u.y.qq.com/cgi-bin/musics.fcg" + versionCode = 13020508 + defaultTimeout = 10 * time.Second + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +) + +var httpClient = &http.Client{ + Timeout: defaultTimeout, +} + +// generateSign computes the signature required by QQ Music's API +func generateSign(data string) string { + h := sha1.Sum([]byte(data)) + hashStr := strings.ToUpper(fmt.Sprintf("%x", h)) + + // Extract chars at specific positions for part1 (filter out index >= 40) + part1Indices := []int{23, 14, 6, 36, 16, 7, 19} + var part1 strings.Builder + for _, idx := range part1Indices { + if idx < len(hashStr) { + part1.WriteByte(hashStr[idx]) + } + } + + // Extract chars at specific positions for part2 + part2Indices := []int{16, 1, 32, 12, 19, 27, 8, 5} + var part2 strings.Builder + for _, idx := range part2Indices { + if idx < len(hashStr) { + part2.WriteByte(hashStr[idx]) + } + } + + // XOR each byte pair with scramble values + scramble := []byte{89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179} + xorBytes := make([]byte, 20) + for i := 0; i < 20 && i < len(scramble); i++ { + hexVal, _ := parseHexPair(hashStr[i*2], hashStr[i*2+1]) + xorBytes[i] = scramble[i] ^ byte(hexVal) + } + + b64 := base64.StdEncoding.EncodeToString(xorBytes) + b64 = strings.NewReplacer("/", "", "+", "", "=", "").Replace(b64) + + return strings.ToLower(fmt.Sprintf("zzc%s%s%s", part1.String(), b64, part2.String())) +} + +// parseHexPair parses two hex characters into a byte value +func parseHexPair(hi, lo byte) (int, error) { + h, err := hexCharToInt(hi) + if err != nil { + return 0, err + } + l, err := hexCharToInt(lo) + if err != nil { + return 0, err + } + return h*16 + l, nil +} + +// hexCharToInt converts a hex character to its integer value +func hexCharToInt(c byte) (int, error) { + switch { + case c >= '0' && c <= '9': + return int(c - '0'), nil + case c >= 'a' && c <= 'f': + return int(c-'a') + 10, nil + case c >= 'A' && c <= 'F': + return int(c-'A') + 10, nil + default: + return 0, fmt.Errorf("invalid hex char: %c", c) + } +} + +// generateGUID creates a random 32-char hex GUID +func generateGUID() string { + const chars = "0123456789ABCDEF" + b := make([]byte, 32) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} + +// buildCommonParams creates the comm section for API requests +func buildCommonParams() QQComm { + return QQComm{ + WID: generateGUID(), + CV: versionCode, + V: versionCode, + QIMEI36: "8888888888888888", + CT: "11", + TmeAppID: "qqmusic", + Format: "json", + InCharset: "utf-8", + OutCharset: "utf-8", + UID: "3931641530", + } +} + +// buildRequestBody creates a QQ Music API request body with the dynamic module.method key +func buildRequestBody(module, method string, param interface{}) ([]byte, string, error) { + key := module + "." + method + // Use ordered map to ensure consistent JSON output + raw := map[string]interface{}{ + "comm": buildCommonParams(), + key: QQAPIModule{ + Module: module, + Method: method, + Param: param, + }, + } + body, err := json.Marshal(raw) + return body, key, err +} + +// doAPIRequest sends a request to the QQ Music unified API and extracts the module result +func doAPIRequest(module, method string, param interface{}) (json.RawMessage, error) { + body, key, err := buildRequestBody(module, method, param) + if err != nil { + return nil, fmt.Errorf("failed to build request: %w", err) + } + + sign := generateSign(string(body)) + requestURL := fmt.Sprintf("%s?sign=%s", apiURL, sign) + + req, err := http.NewRequest("POST", requestURL, strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Referer", "https://y.qq.com/") + req.Header.Set("Origin", "https://y.qq.com") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse the dynamic-keyed response + var raw map[string]json.RawMessage + if err := json.Unmarshal(respBody, &raw); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Check top-level code + if codeRaw, ok := raw["code"]; ok { + var code int + if json.Unmarshal(codeRaw, &code) == nil && code != 0 { + return nil, fmt.Errorf("API error: code %d", code) + } + } + + // Extract the module result using the dynamic key + moduleRaw, ok := raw[key] + if !ok { + return nil, fmt.Errorf("response missing key: %s", key) + } + + var result moduleResult + if err := json.Unmarshal(moduleRaw, &result); err != nil { + return nil, fmt.Errorf("failed to parse module result: %w", err) + } + + if result.Code != 0 { + return nil, fmt.Errorf("API error: code %d", result.Code) + } + + return result.Data, nil +} + +// SearchSongs searches for songs on QQ Music +func SearchSongs(song, artist string, numResults int) ([]SongItem, error) { + query := song + if artist != "" { + query = song + " " + artist + } + + if numResults <= 0 { + numResults = 10 + } + + log.Debugf("%s [QQ] Searching: %s", logcolors.LogSearch, query) + + data, err := doAPIRequest( + "music.search.SearchCgiService", + "DoSearchForQQMusicMobile", + SearchParam{ + SearchID: fmt.Sprintf("%d", rand.Int63()), + Query: query, + SearchType: 0, + NumPerPage: numResults, + PageNum: 1, + Highlight: 1, + Grp: 1, + }, + ) + if err != nil { + return nil, err + } + + var sd searchData + if err := json.Unmarshal(data, &sd); err != nil { + return nil, fmt.Errorf("failed to parse search data: %w", err) + } + + // Try item_song first (newer API), fall back to song.list + songs := sd.Body.ItemSong + if len(songs) == 0 { + songs = sd.Body.Song.List + } + + return songs, nil +} + +// FetchQRCLyrics fetches and decrypts QRC lyrics for a song +func FetchQRCLyrics(songMID string) (string, error) { + log.Debugf("%s [QQ] Fetching lyrics for MID: %s", logcolors.LogLyrics, songMID) + + data, err := doAPIRequest( + "music.musichallSong.PlayLyricInfo", + "GetPlayLyricInfo", + LyricsParam{ + Crypt: 1, + CT: 11, + CV: versionCode, + LrcT: 0, + QRC: 1, + QRCT: 0, + Roma: 0, + RomaT: 0, + Trans: 0, + TransT: 0, + Type: 1, + SongMID: songMID, + }, + ) + if err != nil { + return "", err + } + + var ld lyricsData + if err := json.Unmarshal(data, &ld); err != nil { + return "", fmt.Errorf("failed to parse lyrics data: %w", err) + } + + // Prefer QRC (word-level timing) over plain lyric (LRC) + lyricContent := rawToString(ld.QRC) + if lyricContent == "" { + lyricContent = rawToString(ld.Lyric) + } + if lyricContent == "" { + return "", fmt.Errorf("lyrics content is empty") + } + + // Process the lyric content - could be hex-encoded encrypted QRC or plain text + return processLyricContent(lyricContent) +} + +// processLyricContent handles different lyric content formats +func processLyricContent(content string) (string, error) { + // If it starts with '[', it's already plain LRC/QRC text + if strings.HasPrefix(content, "[") { + return content, nil + } + + // Check if it's hex-encoded (encrypted QRC) + if len(content)%2 == 0 && isHexString(content) { + encrypted, err := hex.DecodeString(content) + if err != nil { + return "", fmt.Errorf("failed to hex-decode lyrics: %w", err) + } + + decrypted, err := qrc.DecodeQRC(encrypted) + if err != nil { + return "", fmt.Errorf("failed to decrypt QRC: %w", err) + } + + return string(decrypted), nil + } + + return content, nil +} + +// isHexString checks if a string contains only hex characters +func isHexString(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return len(s) > 0 +} + +// SelectBestSong selects the best matching song from search results +// Returns the best song and a normalized score (0.0 to 1.0) +func SelectBestSong(songs []SongItem, song, artist string, durationMs int) (*SongItem, float64) { + if len(songs) == 0 { + return nil, 0 + } + + var best *SongItem + bestScore := -1 + // Max possible: 30 (exact song) + 25 (exact artist) + 20 (duration) = 75 + maxPossibleScore := 75 + + songLower := strings.ToLower(song) + artistLower := strings.ToLower(artist) + + for i := range songs { + s := &songs[i] + score := 0 + + titleLower := strings.ToLower(s.Title) + if titleLower == songLower { + score += 30 + } else if strings.Contains(titleLower, songLower) || strings.Contains(songLower, titleLower) { + score += 15 + } + + if artistLower != "" { + singerLower := strings.ToLower(s.SingerNames()) + if singerLower == artistLower { + score += 25 + } else if strings.Contains(singerLower, artistLower) || strings.Contains(artistLower, singerLower) { + score += 10 + } + } + + if durationMs > 0 && s.Interval > 0 { + diff := abs(s.Interval*1000 - durationMs) + if diff < 3000 { + score += 20 + } else if diff < 5000 { + score += 10 + } else if diff < 10000 { + score += 5 + } + } + + if score > bestScore { + bestScore = score + best = s + } + } + + normalizedScore := float64(bestScore) / float64(maxPossibleScore) + if normalizedScore > 1.0 { + normalizedScore = 1.0 + } + if normalizedScore < 0.0 { + normalizedScore = 0.0 + } + + return best, normalizedScore +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// filterSongsByDuration filters songs to those within deltaMs of the target duration +func filterSongsByDuration(songs []SongItem, durationMs, deltaMs int) []SongItem { + var filtered []SongItem + for _, s := range songs { + songDurationMs := s.Interval * 1000 + diff := abs(songDurationMs - durationMs) + if diff <= deltaMs { + filtered = append(filtered, s) + } + } + return filtered +} diff --git a/services/providers/qq/client_test.go b/services/providers/qq/client_test.go new file mode 100644 index 0000000..42bb752 --- /dev/null +++ b/services/providers/qq/client_test.go @@ -0,0 +1,293 @@ +package qq + +import ( + "testing" +) + +func TestGenerateSign(t *testing.T) { + data := `{"comm":{"wid":"ABC","cv":13020508},"music.search.SearchCgiService.DoSearchForQQMusicMobile":{"module":"music.search.SearchCgiService","method":"DoSearchForQQMusicMobile","param":{"query":"test"}}}` + + sign := generateSign(data) + + if len(sign) < 4 || sign[:3] != "zzc" { + t.Errorf("Sign should start with 'zzc', got %q", sign[:min(3, len(sign))]) + } + + if sign != toLower(sign) { + t.Errorf("Sign should be lowercase, got %q", sign) + } + + // Sign should be deterministic + sign2 := generateSign(data) + if sign != sign2 { + t.Errorf("Sign should be deterministic: %q != %q", sign, sign2) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func toLower(s string) string { + result := make([]byte, len(s)) + for i := range s { + c := s[i] + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + return string(result) +} + +func TestGenerateSign_DifferentInputs(t *testing.T) { + sign1 := generateSign("input one") + sign2 := generateSign("input two") + + if sign1 == sign2 { + t.Error("Different inputs should produce different signatures") + } +} + +func TestHexCharToInt(t *testing.T) { + tests := []struct { + input byte + expected int + wantErr bool + }{ + {'0', 0, false}, + {'9', 9, false}, + {'a', 10, false}, + {'f', 15, false}, + {'A', 10, false}, + {'F', 15, false}, + {'g', 0, true}, + {'z', 0, true}, + } + + for _, tt := range tests { + result, err := hexCharToInt(tt.input) + if tt.wantErr && err == nil { + t.Errorf("hexCharToInt(%q): expected error", tt.input) + } + if !tt.wantErr && err != nil { + t.Errorf("hexCharToInt(%q): unexpected error: %v", tt.input, err) + } + if !tt.wantErr && result != tt.expected { + t.Errorf("hexCharToInt(%q) = %d, expected %d", tt.input, result, tt.expected) + } + } +} + +func TestSelectBestSong_ExactMatch(t *testing.T) { + songs := []SongItem{ + {Title: "Shape of You", Singer: []Singer{{Name: "Ed Sheeran"}}, Interval: 230, MID: "abc"}, + {Title: "Perfect", Singer: []Singer{{Name: "Ed Sheeran"}}, Interval: 260, MID: "def"}, + } + + best, score := SelectBestSong(songs, "Shape of You", "Ed Sheeran", 230000) + + if best == nil { + t.Fatal("Expected a best song, got nil") + } + + if best.Title != "Shape of You" { + t.Errorf("Expected 'Shape of You', got %q", best.Title) + } + + if score <= 0 || score > 1 { + t.Errorf("Score should be between 0 and 1, got %f", score) + } +} + +func TestSelectBestSong_PartialMatch(t *testing.T) { + songs := []SongItem{ + {Title: "Shape of You (Official Video)", Singer: []Singer{{Name: "Ed Sheeran"}}, Interval: 230, MID: "a"}, + {Title: "Other Song", Singer: []Singer{{Name: "Other Artist"}}, Interval: 230, MID: "b"}, + } + + best, _ := SelectBestSong(songs, "Shape of You", "Ed Sheeran", 230000) + + if best == nil { + t.Fatal("Expected a best song, got nil") + } + + if best.Title != "Shape of You (Official Video)" { + t.Errorf("Expected partial match, got %q", best.Title) + } +} + +func TestSelectBestSong_DurationBonus(t *testing.T) { + songs := []SongItem{ + {Title: "Test", Singer: []Singer{{Name: "Artist"}}, Interval: 200, MID: "a"}, + {Title: "Test", Singer: []Singer{{Name: "Artist"}}, Interval: 230, MID: "b"}, + {Title: "Test", Singer: []Singer{{Name: "Artist"}}, Interval: 260, MID: "c"}, + } + + best, _ := SelectBestSong(songs, "Test", "Artist", 230000) + + if best == nil { + t.Fatal("Expected a best song, got nil") + } + + if best.Interval != 230 { + t.Errorf("Expected duration 230 (exact match), got %d", best.Interval) + } +} + +func TestSelectBestSong_EmptyList(t *testing.T) { + best, score := SelectBestSong([]SongItem{}, "Test", "Artist", 0) + + if best != nil { + t.Error("Expected nil for empty song list") + } + + if score != 0 { + t.Errorf("Expected score 0, got %f", score) + } +} + +func TestSelectBestSong_CaseInsensitive(t *testing.T) { + songs := []SongItem{ + {Title: "HELLO WORLD", Singer: []Singer{{Name: "TEST ARTIST"}}, Interval: 200, MID: "a"}, + } + + best, _ := SelectBestSong(songs, "hello world", "test artist", 200000) + + if best == nil { + t.Fatal("Expected a match with case-insensitive comparison") + } +} + +func TestSelectBestSong_MultipleSingers(t *testing.T) { + songs := []SongItem{ + { + Title: "Test Song", + Singer: []Singer{{Name: "Artist A"}, {Name: "Artist B"}}, + Interval: 200, + MID: "a", + }, + } + + best, _ := SelectBestSong(songs, "Test Song", "Artist A", 0) + + if best == nil { + t.Fatal("Expected a match with partial artist") + } +} + +func TestFilterSongsByDuration(t *testing.T) { + songs := []SongItem{ + {Title: "Song1", Interval: 200, MID: "a"}, + {Title: "Song2", Interval: 230, MID: "b"}, + {Title: "Song3", Interval: 260, MID: "c"}, + {Title: "Song4", Interval: 300, MID: "d"}, + } + + filtered := filterSongsByDuration(songs, 230000, 10000) + + if len(filtered) != 1 { + t.Errorf("Expected 1 song within delta, got %d", len(filtered)) + } + + if len(filtered) > 0 && filtered[0].Title != "Song2" { + t.Errorf("Expected 'Song2', got %q", filtered[0].Title) + } +} + +func TestFilterSongsByDuration_EmptyInput(t *testing.T) { + filtered := filterSongsByDuration([]SongItem{}, 230000, 10000) + + if len(filtered) != 0 { + t.Errorf("Expected empty slice, got %d items", len(filtered)) + } +} + +func TestFilterSongsByDuration_AllFiltered(t *testing.T) { + songs := []SongItem{ + {Title: "Song1", Interval: 100, MID: "a"}, + {Title: "Song2", Interval: 120, MID: "b"}, + } + + filtered := filterSongsByDuration(songs, 300000, 5000) + + if len(filtered) != 0 { + t.Errorf("Expected 0 songs, got %d", len(filtered)) + } +} + +func TestAbs(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {5, 5}, + {-5, 5}, + {0, 0}, + {-100, 100}, + } + + for _, tt := range tests { + result := abs(tt.input) + if result != tt.expected { + t.Errorf("abs(%d) = %d, expected %d", tt.input, result, tt.expected) + } + } +} + +func TestIsHexString(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"0123456789abcdef", true}, + {"ABCDEF", true}, + {"deadbeef", true}, + {"not hex!", false}, + {"", false}, + {"0g", false}, + } + + for _, tt := range tests { + result := isHexString(tt.input) + if result != tt.expected { + t.Errorf("isHexString(%q) = %v, expected %v", tt.input, result, tt.expected) + } + } +} + +func TestParseHexPair(t *testing.T) { + val, err := parseHexPair('F', 'A') + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if val != 0xFA { + t.Errorf("Expected 0xFA (250), got %d", val) + } +} + +func TestSongItem_SingerNames(t *testing.T) { + tests := []struct { + name string + singers []Singer + expected string + }{ + {"Single singer", []Singer{{Name: "Coldplay"}}, "Coldplay"}, + {"Multiple singers", []Singer{{Name: "A"}, {Name: "B"}, {Name: "C"}}, "A, B, C"}, + {"No singers", []Singer{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := SongItem{Singer: tt.singers} + result := s.SingerNames() + if result != tt.expected { + t.Errorf("SingerNames() = %q, expected %q", result, tt.expected) + } + }) + } +} diff --git a/services/providers/qq/parser.go b/services/providers/qq/parser.go new file mode 100644 index 0000000..76e31b9 --- /dev/null +++ b/services/providers/qq/parser.go @@ -0,0 +1,161 @@ +package qq + +import ( + "regexp" + "strconv" + "strings" + + "lyrics-api-go/services/providers" +) + +var ( + // Line timing: [startMs,durationMs]text + lineTimingRegex = regexp.MustCompile(`^\[(\d+),(\d+)\](.*)$`) + + // Word timing: text(startMs,durationMs) — timing follows the word it describes + wordTimingRegex = regexp.MustCompile(`([^(]+)\((\d+),(\d+)\)`) + + // Metadata: [tag:value] + metadataRegex = regexp.MustCompile(`^\[([a-zA-Z]+):([^\]]*)\]$`) +) + +// ParseQRC parses QRC format lyrics into Lines with word-level timing +func ParseQRC(content string) ([]providers.Line, map[string]string, error) { + var lines []providers.Line + metadata := make(map[string]string) + + rawLines := strings.Split(content, "\n") + + for _, rawLine := range rawLines { + rawLine = strings.TrimSpace(rawLine) + if rawLine == "" { + continue + } + + // Check for metadata tags like [ti:Title], [ar:Artist] + if matches := metadataRegex.FindStringSubmatch(rawLine); len(matches) == 3 { + tag := strings.ToLower(matches[1]) + value := strings.TrimSpace(matches[2]) + + switch tag { + case "ar": + metadata["artist"] = value + case "ti": + metadata["title"] = value + case "al": + metadata["album"] = value + case "by": + metadata["creator"] = value + case "offset": + metadata["offset"] = value + } + continue + } + + // Parse timed lyrics: [startMs,durationMs]word-timing-content + lineMatch := lineTimingRegex.FindStringSubmatch(rawLine) + if lineMatch == nil { + continue + } + + lineStartMs, _ := strconv.ParseInt(lineMatch[1], 10, 64) + lineDurationMs, _ := strconv.ParseInt(lineMatch[2], 10, 64) + lineContent := lineMatch[3] + lineEndMs := lineStartMs + lineDurationMs + + // Parse word-level timing from content + wordMatches := wordTimingRegex.FindAllStringSubmatch(lineContent, -1) + + var syllables []providers.Syllable + var fullText strings.Builder + + if len(wordMatches) > 0 { + for _, wm := range wordMatches { + wordText := wm[1] + wordStartMs, _ := strconv.ParseInt(wm[2], 10, 64) + wordDurationMs, _ := strconv.ParseInt(wm[3], 10, 64) + + // Trim trailing spaces for syllable text but preserve for full line + trimmedText := strings.TrimSpace(wordText) + if trimmedText == "" { + continue + } + + fullText.WriteString(wordText) + + syllables = append(syllables, providers.Syllable{ + Text: trimmedText, + StartTime: strconv.FormatInt(wordStartMs, 10), + EndTime: strconv.FormatInt(wordStartMs+wordDurationMs, 10), + }) + } + } + + text := strings.TrimSpace(fullText.String()) + if text == "" { + continue + } + + line := providers.Line{ + StartTimeMs: strconv.FormatInt(lineStartMs, 10), + EndTimeMs: strconv.FormatInt(lineEndMs, 10), + DurationMs: strconv.FormatInt(lineDurationMs, 10), + Words: text, + Syllables: syllables, + } + + lines = append(lines, line) + } + + return lines, metadata, nil +} + +// DetectLanguage tries to detect language from QRC metadata or content +func DetectLanguage(metadata map[string]string, content string) string { + if lang, ok := metadata["language"]; ok && lang != "" { + return normalizeLanguageCode(lang) + } + + for _, r := range content { + if r >= '\u4e00' && r <= '\u9fff' { + return "zh" + } + if r >= '\u3040' && r <= '\u309f' { // Hiragana + return "ja" + } + if r >= '\u30a0' && r <= '\u30ff' { // Katakana + return "ja" + } + if r >= '\uac00' && r <= '\ud7af' { // Korean + return "ko" + } + } + + return "en" +} + +// normalizeLanguageCode normalizes language names to ISO codes +func normalizeLanguageCode(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + switch lang { + case "英语", "english", "eng": + return "en" + case "中文", "chinese", "chi", "普通话", "国语", "粤语": + return "zh" + case "日语", "japanese", "jpn": + return "ja" + case "韩语", "korean", "kor": + return "ko" + case "西班牙语", "spanish", "spa": + return "es" + case "法语", "french", "fra": + return "fr" + case "德语", "german", "ger": + return "de" + default: + if len(lang) <= 3 { + return lang + } + return "en" + } +} diff --git a/services/providers/qq/parser_test.go b/services/providers/qq/parser_test.go new file mode 100644 index 0000000..88be323 --- /dev/null +++ b/services/providers/qq/parser_test.go @@ -0,0 +1,274 @@ +package qq + +import ( + "testing" +) + +func TestParseQRC_BasicFormat(t *testing.T) { + qrc := `[ti:Test Song] +[ar:Test Artist] +[0,4145]Viva (0,829)la (829,829)Vida (1658,829) +[13268,3323]I (13268,191)used (13459,227)to (13686,492)rule (14178,1253)the (15431,206)world(15637,954)` + + lines, metadata, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 2 { + t.Fatalf("Expected 2 lines, got %d", len(lines)) + } + + // Check metadata + if metadata["title"] != "Test Song" { + t.Errorf("Expected title 'Test Song', got %q", metadata["title"]) + } + if metadata["artist"] != "Test Artist" { + t.Errorf("Expected artist 'Test Artist', got %q", metadata["artist"]) + } + + // Check first line timing + if lines[0].StartTimeMs != "0" { + t.Errorf("Expected StartTimeMs '0', got %q", lines[0].StartTimeMs) + } + if lines[0].EndTimeMs != "4145" { + t.Errorf("Expected EndTimeMs '4145', got %q", lines[0].EndTimeMs) + } + if lines[0].DurationMs != "4145" { + t.Errorf("Expected DurationMs '4145', got %q", lines[0].DurationMs) + } + + // Check first line has syllables with word-level timing + if len(lines[0].Syllables) != 3 { + t.Fatalf("Expected 3 syllables in first line, got %d", len(lines[0].Syllables)) + } + + // Check syllable text + expectedSyllables := []string{"Viva", "la", "Vida"} + for i, expected := range expectedSyllables { + if lines[0].Syllables[i].Text != expected { + t.Errorf("Syllable %d: expected %q, got %q", i, expected, lines[0].Syllables[i].Text) + } + } + + // Check first syllable timing + if lines[0].Syllables[0].StartTime != "0" { + t.Errorf("Expected first syllable StartTime '0', got %q", lines[0].Syllables[0].StartTime) + } + if lines[0].Syllables[0].EndTime != "829" { + t.Errorf("Expected first syllable EndTime '829', got %q", lines[0].Syllables[0].EndTime) + } +} + +func TestParseQRC_SecondLine(t *testing.T) { + qrc := `[13268,3323]I (13268,191)used (13459,227)to (13686,492)rule (14178,1253)the (15431,206)world(15637,954)` + + lines, _, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 1 { + t.Fatalf("Expected 1 line, got %d", len(lines)) + } + + // Check line timing + if lines[0].StartTimeMs != "13268" { + t.Errorf("Expected StartTimeMs '13268', got %q", lines[0].StartTimeMs) + } + if lines[0].EndTimeMs != "16591" { + t.Errorf("Expected EndTimeMs '16591', got %q", lines[0].EndTimeMs) + } + + // Check syllables + if len(lines[0].Syllables) != 6 { + t.Fatalf("Expected 6 syllables, got %d", len(lines[0].Syllables)) + } + + expectedWords := []string{"I", "used", "to", "rule", "the", "world"} + for i, expected := range expectedWords { + if lines[0].Syllables[i].Text != expected { + t.Errorf("Syllable %d: expected %q, got %q", i, expected, lines[0].Syllables[i].Text) + } + } + + // Verify word timing for "used" + if lines[0].Syllables[1].StartTime != "13459" { + t.Errorf("Expected 'used' StartTime '13459', got %q", lines[0].Syllables[1].StartTime) + } + if lines[0].Syllables[1].EndTime != "13686" { + t.Errorf("Expected 'used' EndTime '13686', got %q", lines[0].Syllables[1].EndTime) + } +} + +func TestParseQRC_MetadataOnly(t *testing.T) { + qrc := `[ti:Title] +[ar:Artist] +[al:Album] +[by:Creator] +[offset:500]` + + lines, metadata, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 0 { + t.Errorf("Expected 0 lines, got %d", len(lines)) + } + + expected := map[string]string{ + "title": "Title", + "artist": "Artist", + "album": "Album", + "creator": "Creator", + "offset": "500", + } + + for key, val := range expected { + if metadata[key] != val { + t.Errorf("metadata[%q] = %q, expected %q", key, metadata[key], val) + } + } +} + +func TestParseQRC_EmptyInput(t *testing.T) { + lines, metadata, err := ParseQRC("") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 0 { + t.Errorf("Expected 0 lines, got %d", len(lines)) + } + + if len(metadata) != 0 { + t.Errorf("Expected empty metadata, got %d entries", len(metadata)) + } +} + +func TestParseQRC_EmptyTextLines(t *testing.T) { + // Lines with timing but no word content should be skipped + qrc := `[0,1000] +[1000,2000]Hello (1000,500)world(1500,500)` + + lines, _, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 1 { + t.Errorf("Expected 1 line (empty line skipped), got %d", len(lines)) + } + + if lines[0].Words != "Hello world" { + t.Errorf("Expected 'Hello world', got %q", lines[0].Words) + } +} + +func TestParseQRC_FullLineText(t *testing.T) { + qrc := `[0,4145]Viva (0,829)la (829,829)Vida (1658,829)` + + lines, _, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 1 { + t.Fatalf("Expected 1 line, got %d", len(lines)) + } + + if lines[0].Words != "Viva la Vida" { + t.Errorf("Expected 'Viva la Vida', got %q", lines[0].Words) + } +} + +func TestParseQRC_ChineseContent(t *testing.T) { + qrc := `[ti:晴天] +[ar:周杰伦] +[0,5000]故事(0,1000)的(1000,500)小(1500,500)黄花(2000,1000) +[5000,4000]从(5000,500)出生(5500,1000)那年(6500,1000)就(7500,500)飘着(8000,1000)` + + lines, metadata, err := ParseQRC(qrc) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(lines) != 2 { + t.Fatalf("Expected 2 lines, got %d", len(lines)) + } + + if metadata["title"] != "晴天" { + t.Errorf("Expected title '晴天', got %q", metadata["title"]) + } + + if lines[0].Words != "故事的小黄花" { + t.Errorf("Expected '故事的小黄花', got %q", lines[0].Words) + } + + // Verify syllable count + if len(lines[0].Syllables) != 4 { + t.Errorf("Expected 4 syllables, got %d", len(lines[0].Syllables)) + } +} + +func TestDetectLanguage_ContentHeuristics(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + {"Chinese characters", "你好世界", "zh"}, + {"Japanese hiragana", "こんにちは", "ja"}, + {"Japanese katakana", "コンニチハ", "ja"}, + {"Korean", "안녕하세요", "ko"}, + {"English only", "Hello world", "en"}, + {"Empty content", "", "en"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetectLanguage(nil, tt.content) + if result != tt.expected { + t.Errorf("DetectLanguage() = %q, expected %q", result, tt.expected) + } + }) + } +} + +func TestDetectLanguage_Metadata(t *testing.T) { + metadata := map[string]string{ + "language": "Chinese", + } + + result := DetectLanguage(metadata, "some content") + if result != "zh" { + t.Errorf("Expected 'zh', got %q", result) + } +} + +func TestNormalizeLanguageCode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"中文", "zh"}, + {"chinese", "zh"}, + {"english", "en"}, + {"日语", "ja"}, + {"korean", "ko"}, + {"en", "en"}, + {"zh", "zh"}, + {" english ", "en"}, + {"Klingon", "en"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeLanguageCode(tt.input) + if result != tt.expected { + t.Errorf("normalizeLanguageCode(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/services/providers/qq/qq.go b/services/providers/qq/qq.go new file mode 100644 index 0000000..fb735f2 --- /dev/null +++ b/services/providers/qq/qq.go @@ -0,0 +1,118 @@ +package qq + +import ( + "context" + "fmt" + "lyrics-api-go/config" + "lyrics-api-go/logcolors" + "lyrics-api-go/services/providers" + + log "github.com/sirupsen/logrus" +) + +const ( + ProviderName = "qq" + CachePrefix = "qq_lyrics" +) + +// QQProvider implements the providers.Provider interface for QQ Music lyrics +type QQProvider struct{} + +// NewProvider creates a new QQ provider instance +func NewProvider() *QQProvider { + return &QQProvider{} +} + +// Name returns the provider identifier +func (p *QQProvider) Name() string { + return ProviderName +} + +// CacheKeyPrefix returns the cache key prefix for this provider +func (p *QQProvider) CacheKeyPrefix() string { + return CachePrefix +} + +// FetchLyrics fetches lyrics from QQ Music API +func (p *QQProvider) FetchLyrics(ctx context.Context, song, artist, album string, durationMs int) (*providers.LyricsResult, error) { + conf := config.Get() + + if song == "" && artist == "" { + return nil, providers.NewProviderError(ProviderName, "song name and artist name cannot both be empty", nil) + } + + log.Infof("%s [QQ] Searching: %s - %s", logcolors.LogSearch, song, artist) + + songs, err := SearchSongs(song, artist, 10) + if err != nil { + return nil, providers.NewProviderError(ProviderName, "song search failed", err) + } + + if len(songs) == 0 { + return nil, providers.NewProviderError(ProviderName, fmt.Sprintf("no songs found for: %s - %s", song, artist), nil) + } + + // Apply duration filter if duration is provided + filteredSongs := songs + if durationMs > 0 { + deltaMs := conf.Configuration.DurationMatchDeltaMs + filteredSongs = filterSongsByDuration(songs, durationMs, deltaMs) + if len(filteredSongs) == 0 { + return nil, providers.NewProviderError(ProviderName, + fmt.Sprintf("no songs within %dms of duration %dms", deltaMs, durationMs), nil) + } + log.Infof("%s [QQ] %d/%d songs passed duration filter (delta: %dms)", + logcolors.LogDurationFilter, len(filteredSongs), len(songs), deltaMs) + } + + bestSong, songScore := SelectBestSong(filteredSongs, song, artist, durationMs) + if bestSong == nil { + return nil, providers.NewProviderError(ProviderName, "no suitable song match found", nil) + } + + // Check minimum similarity threshold + minScore := conf.Configuration.MinSimilarityScore + if songScore < minScore { + return nil, providers.NewProviderError(ProviderName, + fmt.Sprintf("best match score %.2f below threshold %.2f for: %s - %s", + songScore, minScore, song, artist), nil) + } + + log.Infof("%s [QQ] Found song: %s - %s (score: %.2f, mid: %s)", + logcolors.LogMatch, bestSong.Title, bestSong.SingerNames(), songScore, bestSong.MID) + + // Fetch and decrypt QRC lyrics + qrcContent, err := FetchQRCLyrics(bestSong.MID) + if err != nil { + return nil, providers.NewProviderError(ProviderName, "failed to fetch QRC lyrics", err) + } + + // Parse QRC content + lines, metadata, parseErr := ParseQRC(qrcContent) + if parseErr != nil { + log.Warnf("%s [QQ] Failed to parse QRC: %v", logcolors.LogWarning, parseErr) + } + + // Detect language + language := DetectLanguage(metadata, qrcContent) + + log.Infof("%s [QQ] Fetched lyrics for: %s - %s (%d bytes, %d lines)", + logcolors.LogSuccess, bestSong.Title, bestSong.SingerNames(), len(qrcContent), len(lines)) + + result := &providers.LyricsResult{ + RawLyrics: qrcContent, + Lines: lines, + TrackDurationMs: bestSong.Interval * 1000, + Score: songScore, + Provider: ProviderName, + Language: normalizeLanguageCode(language), + IsRTL: providers.IsRTLLanguage(normalizeLanguageCode(language)), + } + + return result, nil +} + +// init registers the QQ provider with the global registry +func init() { + providers.Register(NewProvider()) +} diff --git a/services/providers/qq/qq_test.go b/services/providers/qq/qq_test.go new file mode 100644 index 0000000..14c52a4 --- /dev/null +++ b/services/providers/qq/qq_test.go @@ -0,0 +1,63 @@ +package qq + +import ( + "testing" + + "lyrics-api-go/services/providers" +) + +func TestQQProvider_Name(t *testing.T) { + p := NewProvider() + + if p.Name() != ProviderName { + t.Errorf("Name() = %q, expected %q", p.Name(), ProviderName) + } + + if p.Name() != "qq" { + t.Errorf("Name() = %q, expected %q", p.Name(), "qq") + } +} + +func TestQQProvider_CacheKeyPrefix(t *testing.T) { + p := NewProvider() + + if p.CacheKeyPrefix() != CachePrefix { + t.Errorf("CacheKeyPrefix() = %q, expected %q", p.CacheKeyPrefix(), CachePrefix) + } + + if p.CacheKeyPrefix() != "qq_lyrics" { + t.Errorf("CacheKeyPrefix() = %q, expected %q", p.CacheKeyPrefix(), "qq_lyrics") + } +} + +func TestNewProvider(t *testing.T) { + p := NewProvider() + + if p == nil { + t.Fatal("NewProvider() returned nil") + } + + _, ok := interface{}(p).(*QQProvider) + if !ok { + t.Error("NewProvider() should return *QQProvider") + } +} + +func TestQQProvider_ImplementsInterface(t *testing.T) { + var _ providers.Provider = &QQProvider{} + var _ providers.Provider = NewProvider() +} + +func TestConstants(t *testing.T) { + t.Run("ProviderName constant", func(t *testing.T) { + if ProviderName != "qq" { + t.Errorf("ProviderName = %q, expected %q", ProviderName, "qq") + } + }) + + t.Run("CachePrefix constant", func(t *testing.T) { + if CachePrefix != "qq_lyrics" { + t.Errorf("CachePrefix = %q, expected %q", CachePrefix, "qq_lyrics") + } + }) +} diff --git a/services/providers/qq/types.go b/services/providers/qq/types.go new file mode 100644 index 0000000..41a0d39 --- /dev/null +++ b/services/providers/qq/types.go @@ -0,0 +1,133 @@ +package qq + +import "encoding/json" + +// QQComm contains common request parameters +type QQComm struct { + WID string `json:"wid"` + CV int `json:"cv"` + V int `json:"v"` + QIMEI36 string `json:"QIMEI36"` + CT string `json:"ct"` + TmeAppID string `json:"tmeAppID"` + Format string `json:"format"` + InCharset string `json:"inCharset"` + OutCharset string `json:"outCharset"` + UID string `json:"uid"` +} + +// QQAPIModule represents a single module call within the unified API request +type QQAPIModule struct { + Module string `json:"module"` + Method string `json:"method"` + Param interface{} `json:"param"` +} + +// SearchParam contains search request parameters +type SearchParam struct { + SearchID string `json:"searchid"` + Query string `json:"query"` + SearchType int `json:"search_type"` + NumPerPage int `json:"num_per_page"` + PageNum int `json:"page_num"` + Highlight int `json:"highlight"` + Grp int `json:"grp"` +} + +// LyricsParam contains lyrics fetch request parameters +type LyricsParam struct { + Crypt int `json:"crypt"` + CT int `json:"ct"` + CV int `json:"cv"` + LrcT int `json:"lrc_t"` + QRC int `json:"qrc"` + QRCT int `json:"qrc_t"` + Roma int `json:"roma"` + RomaT int `json:"roma_t"` + Trans int `json:"trans"` + TransT int `json:"trans_t"` + Type int `json:"type"` + SongMID string `json:"songMid"` +} + +// SongItem represents a song from QQ Music search results +type SongItem struct { + Title string `json:"title"` + Singer []Singer `json:"singer"` + Album Album `json:"album"` + Interval int `json:"interval"` // Duration in seconds + MID string `json:"mid"` + ID int `json:"id"` +} + +// Singer represents an artist in QQ Music API +type Singer struct { + Name string `json:"name"` + MID string `json:"mid"` +} + +// Album represents an album in QQ Music API +type Album struct { + Name string `json:"name"` + MID string `json:"mid"` + Title string `json:"title"` +} + +// SingerNames returns a comma-separated string of all singer names +func (s SongItem) SingerNames() string { + if len(s.Singer) == 0 { + return "" + } + names := make([]string, len(s.Singer)) + for i, singer := range s.Singer { + names[i] = singer.Name + } + result := names[0] + for i := 1; i < len(names); i++ { + result += ", " + names[i] + } + return result +} + +// apiResponse is used for raw JSON parsing of the dynamic-keyed response +type apiResponse struct { + Code int `json:"code"` + Raw json.RawMessage `json:"-"` +} + +// moduleResult represents a single module result within the API response +type moduleResult struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` +} + +// searchData represents the search result data +type searchData struct { + Body struct { + ItemSong []SongItem `json:"item_song"` + Song struct { + List []SongItem `json:"list"` + } `json:"song"` + } `json:"body"` +} + +// lyricsData represents the lyrics result data +// QRC and Lyric can be either strings (hex content) or numbers (0 when unavailable) +type lyricsData struct { + Lyric json.RawMessage `json:"lyric"` + QRC json.RawMessage `json:"qrc"` + Trans json.RawMessage `json:"trans"` +} + +// rawToString extracts a string from a JSON value that may be a string or number +func rawToString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + // If it's a number (like 0), it's not a valid lyrics value + return "" +}