255 lines
6.5 KiB
Go
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
|
|
}
|