Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

255 lines
6.5 KiB
Go

package lyrics
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// ErrNotFound is returned when no lyrics could be found from any source.
var ErrNotFound = errors.New("no lyrics found")
// Line represents a single timestamped lyrical line.
type Line struct {
Start time.Duration
Text string
}
// httpClient is reused across all lyrics API calls.
var httpClient = &http.Client{Timeout: 10 * time.Second}
// maxResponseBody limits API responses to 2 MB.
const maxResponseBody = 2 << 20
type lrcResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
PlainLyrics string `json:"plainLyrics"`
}
type ncmSearchResponse struct {
Result struct {
Songs []struct {
Id int `json:"id"`
} `json:"songs"`
} `json:"result"`
}
type ncmLyricResponse struct {
Lrc struct {
Lyric string `json:"lyric"`
} `json:"lrc"`
}
var lrcRegex = regexp.MustCompile(`\[(\d{2,}):(\d{2})\.(\d{2,3})\](.*)`)
// cleanQuery strips noise from a search query: bracketed text like "[Official Video]",
// parenthesized text like "(Lyric Video)", and common video/audio label suffixes.
//
// The label words are only stripped when they form a genuine trailing label
// (after a dash, or as an "official video/audio" / "lyric(s) video" phrase),
// never as a bare substring. Otherwise legitimate titles like "Videotape",
// "Audioslave", or "Video Games" would be erased.
var noiseRegex = regexp.MustCompile(`(?i)(?:` +
`\[.*?\]` + // [Official Video]
`|\(.*?\)` + // (Lyric Video)
`|\s*-\s*(?:official|lyric|audio|video).*` + // - Official Video
`|\s+official(?:\s+music)?\s+(?:video|audio).*` + // Official Music Video
`|\s+lyrics?\s+video.*` + // Lyric Video / Lyrics Video
`)`)
func cleanQuery(str string) string {
s := noiseRegex.ReplaceAllString(str, "")
return strings.TrimSpace(s)
}
// Fetch requests lyrics for the given artist and title.
// It tries LRCLIB first, then falls back to NetEase Cloud Music.
// Returns ErrNotFound if neither source has lyrics.
//
// For YouTube/SoundCloud tracks where Artist is the uploader and Title
// contains "Artist - Song", the title is split to build a better query.
func Fetch(artist, title string) ([]Line, error) {
if artist == "" && title == "" {
return nil, ErrNotFound
}
// YouTube titles often embed the real artist: "Artist - Song (Official Video)".
// If the title contains " - ", prefer that split over the uploader name.
if a, t, ok := strings.Cut(title, " - "); ok {
a = cleanQuery(strings.TrimSpace(a))
t = cleanQuery(strings.TrimSpace(t))
if a != "" && t != "" {
artist = a
title = t
}
}
query := cleanQuery(artist) + " " + cleanQuery(title)
query = strings.TrimSpace(query)
if query == "" {
query = artist + " " + title
}
// Try LRCLIB first.
lines, err := fetchLRCLIB(query)
if err == nil && len(lines) > 0 {
return lines, nil
}
// Fallback to NetEase Cloud Music.
lines, err = fetchNetEase(query)
if err == nil && len(lines) > 0 {
return lines, nil
}
return nil, ErrNotFound
}
// ParseEmbedded converts lyrics read from local file tags into display lines.
// Timestamped LRC data remains synced; plain text is returned as scrollable
// lines at timestamp 0.
func ParseEmbedded(data string) []Line {
data = strings.TrimSpace(data)
if data == "" {
return nil
}
lines := parseLRC(data)
if len(lines) > 0 {
return lines
}
for raw := range strings.SplitSeq(data, "\n") {
lines = append(lines, Line{Start: 0, Text: strings.TrimSpace(raw)})
}
return lines
}
func fetchLRCLIB(query string) ([]Line, error) {
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "cliamp")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("lrclib: %s", resp.Status)
}
var results []lrcResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&results); err != nil {
return nil, err
}
if len(results) == 0 {
return nil, ErrNotFound
}
// Prefer synced lyrics.
for _, r := range results {
if r.SyncedLyrics != "" {
return parseLRC(r.SyncedLyrics), nil
}
}
// Fallback to plain lyrics (all lines at timestamp 0).
if results[0].PlainLyrics != "" {
var lines []Line
for raw := range strings.SplitSeq(results[0].PlainLyrics, "\n") {
lines = append(lines, Line{Start: 0, Text: strings.TrimSpace(raw)})
}
return lines, nil
}
return nil, ErrNotFound
}
func fetchNetEase(query string) ([]Line, error) {
data := url.Values{}
data.Set("s", query)
data.Set("type", "1")
data.Set("limit", "1")
req, err := http.NewRequest("POST", "http://music.163.com/api/search/get/web", strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", "http://music.163.com")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("netease: %s", resp.Status)
}
var searchRes ncmSearchResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(&searchRes); err != nil {
return nil, err
}
if len(searchRes.Result.Songs) == 0 {
return nil, ErrNotFound
}
songID := searchRes.Result.Songs[0].Id
lyricURL := fmt.Sprintf("http://music.163.com/api/song/lyric?id=%d&lv=1&kv=1&tv=-1", songID)
lresp, err := httpClient.Get(lyricURL)
if err != nil {
return nil, err
}
defer lresp.Body.Close()
var lyricRes ncmLyricResponse
if err := json.NewDecoder(io.LimitReader(lresp.Body, maxResponseBody)).Decode(&lyricRes); err != nil {
return nil, err
}
if lyricRes.Lrc.Lyric == "" {
return nil, ErrNotFound
}
return parseLRC(lyricRes.Lrc.Lyric), nil
}
// parseLRC converts standard LRC string blocks into a slice of timestamped Lines.
func parseLRC(data string) []Line {
var lines []Line
for raw := range strings.SplitSeq(data, "\n") {
matches := lrcRegex.FindStringSubmatch(raw)
if len(matches) == 5 {
mins, _ := strconv.Atoi(matches[1])
secs, _ := strconv.Atoi(matches[2])
ms, _ := strconv.Atoi(matches[3])
// If LRC millisecond part is hundredths (2 chars), scale it.
if len(matches[3]) == 2 {
ms *= 10
}
start := time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second + time.Duration(ms)*time.Millisecond
text := strings.TrimSpace(matches[4])
lines = append(lines, Line{Start: start, Text: text})
}
}
return lines
}