Compare commits
4
Commits
v2026.08.25-r4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d814d6636 | ||
|
|
6e9f0ac648 | ||
|
|
783e620214 | ||
|
|
bfd84ccdb5 |
+3
-15
@@ -6,23 +6,11 @@ COPY *.go ./
|
|||||||
COPY web ./web
|
COPY web ./web
|
||||||
RUN go test ./... \
|
RUN go test ./... \
|
||||||
&& go vet ./... \
|
&& go vet ./... \
|
||||||
&& mkdir -p /out/download \
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/lrc-local .
|
||||||
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-windows-amd64.exe . \
|
|
||||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-linux-amd64 . \
|
|
||||||
&& cp /out/download/lrc-local-linux-amd64 /out/lrc-local-server \
|
|
||||||
&& CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-darwin-arm64 . \
|
|
||||||
&& CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-darwin-amd64 . \
|
|
||||||
&& cd /out/download \
|
|
||||||
&& tar -czf lrc-local-linux-amd64.tar.gz lrc-local-linux-amd64 \
|
|
||||||
&& tar -czf lrc-local-darwin-arm64.tar.gz lrc-local-darwin-arm64 \
|
|
||||||
&& tar -czf lrc-local-darwin-amd64.tar.gz lrc-local-darwin-amd64 \
|
|
||||||
&& rm lrc-local-linux-amd64 lrc-local-darwin-arm64 lrc-local-darwin-amd64 \
|
|
||||||
&& sha256sum lrc-local-* > SHA256SUMS
|
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
COPY --from=builder /out/lrc-local-server /lrc-local
|
COPY --from=builder /out/lrc-local /lrc-local
|
||||||
COPY --from=builder /out/download /srv/download
|
|
||||||
USER 65534:65534
|
USER 65534:65534
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/lrc-local", "--public", "--listen", "0.0.0.0:8080", "--origin", "https://lrc.flechazo.xin", "--downloads", "/srv/download"]
|
ENTRYPOINT ["/lrc-local", "--public", "--listen", "0.0.0.0:8080", "--origin", "https://lrc.flechazo.xin"]
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
# LRC Local
|
# LRC Local
|
||||||
|
|
||||||
一个可直接在网页使用、也提供可选本机版的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。
|
`lrc.flechazo.xin` 的 Go 网页服务。
|
||||||
|
|
||||||
网页:<https://lrc.flechazo.xin>
|
网页:<https://lrc.flechazo.xin>
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 搜索歌曲并显示歌手、专辑和时长。
|
- 搜索歌曲并显示歌手、专辑和时长。
|
||||||
- 导出原文 `.lrc`、翻译 `.trans.lrc` 或无时间轴 `.txt`。
|
- 完整歌词预览,可按歌曲实际内容选择仅原文、附带翻译、附带“音”,或同时附带两者。
|
||||||
|
- 导出原文 LRC/TXT;有真实翻译时才提供翻译 LRC/TXT,有罗马音、韩语音译或方言注音时才提供“音” LRC/TXT。
|
||||||
|
- 歌词来自 QQ 音乐 `GetPlayLyricInfo` 返回的原文、翻译与 `roma` 字段;本站不进行机器翻译,也不自行生成注音。
|
||||||
- 网页版只转发到源码中固定的 QQ 音乐接口,不保存搜索词、歌词或历史。
|
- 网页版只转发到源码中固定的 QQ 音乐接口,不保存搜索词、歌词或历史。
|
||||||
- 可选本机版只监听随机的 `127.0.0.1` 端口。
|
- 可选本机版已迁移到独立的 Windows C 仓库:<https://git.sighs.cc/Flechazo/lrc-local-win>。
|
||||||
|
- 主站下载入口直接指向该仓库的 Gitea Release;本仓库不再构建或携带本机版程序。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
VERSION: v2026.08.25-r4
|
VERSION: v2026.08.27-r8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8083:8080"
|
- "127.0.0.1:8083:8080"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ func main() {
|
|||||||
publicMode := flag.Bool("public", false, "run the public web service")
|
publicMode := flag.Bool("public", false, "run the public web service")
|
||||||
listenAddress := flag.String("listen", "0.0.0.0:8080", "public web service listen address")
|
listenAddress := flag.String("listen", "0.0.0.0:8080", "public web service listen address")
|
||||||
publicOrigin := flag.String("origin", "https://lrc.flechazo.xin", "allowed public browser origin")
|
publicOrigin := flag.String("origin", "https://lrc.flechazo.xin", "allowed public browser origin")
|
||||||
downloadDirectory := flag.String("downloads", "", "directory containing optional local-app downloads")
|
|
||||||
healthcheckURL := flag.String("healthcheck", "", "check a running service and exit")
|
healthcheckURL := flag.String("healthcheck", "", "check a running service and exit")
|
||||||
showVersion := flag.Bool("version", false, "print version and exit")
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -47,7 +46,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if *publicMode {
|
if *publicMode {
|
||||||
runPublic(*listenAddress, *publicOrigin, *downloadDirectory)
|
runPublic(*listenAddress, *publicOrigin)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runLocal(*noOpen)
|
runLocal(*noOpen)
|
||||||
@@ -96,7 +95,7 @@ func runLocal(noOpen bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPublic(listenAddress, origin, downloadDirectory string) {
|
func runPublic(listenAddress, origin string) {
|
||||||
listener, err := net.Listen("tcp4", listenAddress)
|
listener, err := net.Listen("tcp4", listenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -105,7 +104,7 @@ func runPublic(listenAddress, origin, downloadDirectory string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
app, err := newPublicApp(origin, downloadDirectory, webFS, newQQClient())
|
app, err := newPublicApp(origin, webFS, newQQClient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-37
@@ -7,8 +7,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,14 +17,6 @@ const (
|
|||||||
publicGlobalLimit = 300
|
publicGlobalLimit = 300
|
||||||
)
|
)
|
||||||
|
|
||||||
var publicDownloadNames = map[string]struct{}{
|
|
||||||
"SHA256SUMS": {},
|
|
||||||
"lrc-local-windows-amd64.exe": {},
|
|
||||||
"lrc-local-linux-amd64.tar.gz": {},
|
|
||||||
"lrc-local-darwin-arm64.tar.gz": {},
|
|
||||||
"lrc-local-darwin-amd64.tar.gz": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
type rateWindow struct {
|
type rateWindow struct {
|
||||||
started time.Time
|
started time.Time
|
||||||
seen time.Time
|
seen time.Time
|
||||||
@@ -36,7 +26,6 @@ type rateWindow struct {
|
|||||||
type publicApp struct {
|
type publicApp struct {
|
||||||
origin string
|
origin string
|
||||||
host string
|
host string
|
||||||
downloadDir string
|
|
||||||
web fs.FS
|
web fs.FS
|
||||||
core *localApp
|
core *localApp
|
||||||
sem chan struct{}
|
sem chan struct{}
|
||||||
@@ -45,14 +34,14 @@ type publicApp struct {
|
|||||||
clients map[string]rateWindow
|
clients map[string]rateWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPublicApp(origin, downloadDir string, web fs.FS, qq qqAPI) (*publicApp, error) {
|
func newPublicApp(origin string, web fs.FS, qq qqAPI) (*publicApp, error) {
|
||||||
parsed, err := url.Parse(origin)
|
parsed, err := url.Parse(origin)
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" {
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" {
|
||||||
return nil, errors.New("public origin must be an HTTPS origin without a path")
|
return nil, errors.New("public origin must be an HTTPS origin without a path")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &publicApp{
|
return &publicApp{
|
||||||
origin: origin, host: parsed.Host, downloadDir: downloadDir, web: web,
|
origin: origin, host: parsed.Host, web: web,
|
||||||
core: newApp(origin, "", web, qq), sem: make(chan struct{}, 12),
|
core: newApp(origin, "", web, qq), sem: make(chan struct{}, 12),
|
||||||
global: rateWindow{started: now, seen: now}, clients: make(map[string]rateWindow),
|
global: rateWindow{started: now, seen: now}, clients: make(map[string]rateWindow),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -68,11 +57,19 @@ func (a *publicApp) routes() http.Handler {
|
|||||||
})
|
})
|
||||||
mux.HandleFunc("POST /api/search", a.withAPI(a.core.search))
|
mux.HandleFunc("POST /api/search", a.withAPI(a.core.search))
|
||||||
mux.HandleFunc("POST /api/lyrics", a.withAPI(a.core.lyrics))
|
mux.HandleFunc("POST /api/lyrics", a.withAPI(a.core.lyrics))
|
||||||
mux.HandleFunc("GET /download/{name}", a.download)
|
|
||||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
return a.core.securityHeaders(mux)
|
return a.securityHeaders(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *publicApp) securityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *publicApp) index(w http.ResponseWriter, r *http.Request) {
|
func (a *publicApp) index(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -97,28 +94,6 @@ func (a *publicApp) serveFile(w http.ResponseWriter, r *http.Request, name, cont
|
|||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *publicApp) download(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := r.PathValue("name")
|
|
||||||
if _, allowed := publicDownloadNames[name]; !allowed || a.downloadDir == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := filepath.Join(a.downloadDir, name)
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil || !info.Mode().IsRegular() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
|
||||||
http.ServeContent(w, r, name, info.ModTime(), file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *publicApp) withAPI(next http.HandlerFunc) http.HandlerFunc {
|
func (a *publicApp) withAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
|||||||
+14
-29
@@ -3,21 +3,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestPublicApp(t *testing.T, downloads string) (*publicApp, http.Handler) {
|
func newTestPublicApp(t *testing.T) (*publicApp, http.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
web := fstest.MapFS{
|
web := fstest.MapFS{
|
||||||
"app.html": &fstest.MapFile{Data: []byte("public app")},
|
"app.html": &fstest.MapFile{Data: []byte("public app")},
|
||||||
"app.js": &fstest.MapFile{Data: []byte("js")},
|
"app.js": &fstest.MapFile{Data: []byte("js")},
|
||||||
"styles.css": &fstest.MapFile{Data: []byte("css")},
|
"styles.css": &fstest.MapFile{Data: []byte("css")},
|
||||||
}
|
}
|
||||||
app, err := newPublicApp("https://lrc.flechazo.xin", downloads, web, fakeQQ{})
|
app, err := newPublicApp("https://lrc.flechazo.xin", web, fakeQQ{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -33,16 +31,19 @@ func publicRequest(method, path, body string) *http.Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicPageNeedsNoSession(t *testing.T) {
|
func TestPublicPageNeedsNoSession(t *testing.T) {
|
||||||
_, handler := newTestPublicApp(t, "")
|
_, handler := newTestPublicApp(t)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, publicRequest(http.MethodGet, "/", ""))
|
handler.ServeHTTP(w, publicRequest(http.MethodGet, "/", ""))
|
||||||
if w.Code != http.StatusOK || w.Body.String() != "public app" {
|
if w.Code != http.StatusOK || w.Body.String() != "public app" {
|
||||||
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
|
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("X-Frame-Options") != "DENY" {
|
||||||
|
t.Fatal("public application safety headers are missing")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicAPIRejectsWrongOrigin(t *testing.T) {
|
func TestPublicAPIRejectsWrongOrigin(t *testing.T) {
|
||||||
_, handler := newTestPublicApp(t, "")
|
_, handler := newTestPublicApp(t)
|
||||||
request := publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":2}`)
|
request := publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":2}`)
|
||||||
request.Header.Set("Origin", "https://attacker.example")
|
request.Header.Set("Origin", "https://attacker.example")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -53,7 +54,7 @@ func TestPublicAPIRejectsWrongOrigin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicSearchWorksWithoutCookie(t *testing.T) {
|
func TestPublicSearchWorksWithoutCookie(t *testing.T) {
|
||||||
_, handler := newTestPublicApp(t, "")
|
_, handler := newTestPublicApp(t)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"测试","limit":2}`))
|
handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"测试","limit":2}`))
|
||||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "测试") {
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "测试") {
|
||||||
@@ -62,7 +63,7 @@ func TestPublicSearchWorksWithoutCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicPerClientRateLimit(t *testing.T) {
|
func TestPublicPerClientRateLimit(t *testing.T) {
|
||||||
_, handler := newTestPublicApp(t, "")
|
_, handler := newTestPublicApp(t)
|
||||||
for attempt := 1; attempt <= publicClientLimit+1; attempt++ {
|
for attempt := 1; attempt <= publicClientLimit+1; attempt++ {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":1}`))
|
handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":1}`))
|
||||||
@@ -75,33 +76,17 @@ func TestPublicPerClientRateLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicDownloadWhitelistAndRange(t *testing.T) {
|
func TestPublicDoesNotServeLocalBinaries(t *testing.T) {
|
||||||
directory := t.TempDir()
|
_, handler := newTestPublicApp(t)
|
||||||
name := "lrc-local-windows-amd64.exe"
|
|
||||||
if err := os.WriteFile(filepath.Join(directory, name), []byte("0123456789"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, handler := newTestPublicApp(t, directory)
|
|
||||||
request := publicRequest(http.MethodGet, "/download/"+name, "")
|
|
||||||
request.Header.Set("Range", "bytes=2-5")
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(w, request)
|
handler.ServeHTTP(w, publicRequest(http.MethodGet, "/download/lrc-local-windows-amd64.exe", ""))
|
||||||
if w.Code != http.StatusPartialContent || w.Body.String() != "2345" {
|
|
||||||
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Header().Get("Content-Disposition"), name) {
|
|
||||||
t.Fatal("download disposition is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, publicRequest(http.MethodGet, "/download/not-allowed", ""))
|
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Fatalf("unexpected non-whitelist status %d", w.Code)
|
t.Fatalf("expected removed local download route to return 404, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicOriginConfigurationMustUseHTTPS(t *testing.T) {
|
func TestPublicOriginConfigurationMustUseHTTPS(t *testing.T) {
|
||||||
_, err := newPublicApp("http://lrc.flechazo.xin", "", fstest.MapFS{}, fakeQQ{})
|
_, err := newPublicApp("http://lrc.flechazo.xin", fstest.MapFS{}, fakeQQ{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected insecure public origin to be rejected")
|
t.Fatal("expected insecure public origin to be rejected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,7 +15,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp"
|
searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp"
|
||||||
lyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg"
|
musicuEndpoint = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||||
|
legacyLyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg"
|
||||||
maxUpstreamBytes = 4 << 20
|
maxUpstreamBytes = 4 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ type song struct {
|
|||||||
type lyricResult struct {
|
type lyricResult struct {
|
||||||
Lyric string `json:"lyric"`
|
Lyric string `json:"lyric"`
|
||||||
Trans string `json:"trans"`
|
Trans string `json:"trans"`
|
||||||
|
Roma string `json:"roma"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ type qqAPI interface {
|
|||||||
type qqClient struct {
|
type qqClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchEndpoint string
|
searchEndpoint string
|
||||||
|
musicuEndpoint string
|
||||||
lyricsEndpoint string
|
lyricsEndpoint string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ func newQQClient() *qqClient {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
searchEndpoint: searchEndpoint,
|
searchEndpoint: searchEndpoint,
|
||||||
lyricsEndpoint: lyricsEndpoint,
|
musicuEndpoint: musicuEndpoint,
|
||||||
|
lyricsEndpoint: legacyLyricsEndpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +117,54 @@ func (c *qqClient) Search(keyword string, limit int) ([]song, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *qqClient) Lyrics(mid string) (lyricResult, error) {
|
func (c *qqClient) Lyrics(mid string) (lyricResult, error) {
|
||||||
|
result, err := c.musicuLyrics(mid)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return c.legacyLyrics(mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *qqClient) musicuLyrics(mid string) (lyricResult, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"comm": map[string]any{"ct": 24, "cv": 0, "format": "json", "platform": "yqq.json"},
|
||||||
|
"req_1": map[string]any{
|
||||||
|
"module": "music.musichallSong.PlayLyricInfo",
|
||||||
|
"method": "GetPlayLyricInfo",
|
||||||
|
"param": map[string]any{
|
||||||
|
"songMID": mid, "songID": 0, "crypt": 0, "qrc": 0, "trans": 1, "roma": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Request struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Lyric string `json:"lyric"`
|
||||||
|
Trans string `json:"trans"`
|
||||||
|
Roma string `json:"roma"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"req_1"`
|
||||||
|
}
|
||||||
|
if err := c.postJSON(c.musicuEndpoint, payload, &response); err != nil {
|
||||||
|
return lyricResult{}, err
|
||||||
|
}
|
||||||
|
if response.Code != 0 || response.Request.Code != 0 {
|
||||||
|
return lyricResult{}, errors.New("QQ Music lyrics request failed")
|
||||||
|
}
|
||||||
|
lyric, err := decodeLyricBase64(response.Request.Data.Lyric)
|
||||||
|
if err != nil || strings.TrimSpace(lyric) == "" {
|
||||||
|
return lyricResult{}, errors.New("lyrics are not available for this track")
|
||||||
|
}
|
||||||
|
trans, _ := decodeLyricBase64(response.Request.Data.Trans)
|
||||||
|
roma := ""
|
||||||
|
if response.Request.Data.Roma != "" {
|
||||||
|
roma, _ = decryptQRCLyric(response.Request.Data.Roma)
|
||||||
|
}
|
||||||
|
return lyricResult{Lyric: lyric, Trans: trans, Roma: roma, Code: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *qqClient) legacyLyrics(mid string) (lyricResult, error) {
|
||||||
query := url.Values{
|
query := url.Values{
|
||||||
"format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"},
|
"format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"},
|
||||||
}
|
}
|
||||||
@@ -125,11 +178,42 @@ func (c *qqClient) Lyrics(mid string) (lyricResult, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeLyricBase64(value string) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("QQ Music returned invalid lyric encoding")
|
||||||
|
}
|
||||||
|
if len(decoded) > maxUpstreamBytes {
|
||||||
|
return "", errors.New("decoded lyrics exceeded the safety limit")
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *qqClient) postJSON(endpoint string, payload, destination any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
return c.doJSON(request, destination)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error {
|
func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error {
|
||||||
request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return c.doJSON(request, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *qqClient) doJSON(request *http.Request, destination any) error {
|
||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36")
|
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36")
|
||||||
request.Header.Set("Referer", "https://y.qq.com/")
|
request.Header.Set("Referer", "https://y.qq.com/")
|
||||||
request.Header.Set("Origin", "https://y.qq.com")
|
request.Header.Set("Origin", "https://y.qq.com")
|
||||||
|
|||||||
+47
-2
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,6 +17,49 @@ func TestUnwrapJSONP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQQClientMusicuLyricsRequestsTranslationAndPhonetics(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Request struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Param struct {
|
||||||
|
MID string `json:"songMID"`
|
||||||
|
Trans int `json:"trans"`
|
||||||
|
Roma int `json:"roma"`
|
||||||
|
QRC int `json:"qrc"`
|
||||||
|
} `json:"param"`
|
||||||
|
} `json:"req_1"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Request.Module != "music.musichallSong.PlayLyricInfo" || body.Request.Method != "GetPlayLyricInfo" || body.Request.Param.MID != "0004jPDk2eB2dt" {
|
||||||
|
t.Fatalf("unexpected request body: %#v", body)
|
||||||
|
}
|
||||||
|
if body.Request.Param.Trans != 1 || body.Request.Param.Roma != 1 || body.Request.Param.QRC != 0 {
|
||||||
|
t.Fatalf("required lyric variants were not requested: %#v", body.Request.Param)
|
||||||
|
}
|
||||||
|
lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]原文"))
|
||||||
|
trans := base64.StdEncoding.EncodeToString([]byte("[00:01.00]translation"))
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"req_1":{"code":0,"data":{"lyric":"` + lyric + `","trans":"` + trans + `"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := newQQClient()
|
||||||
|
client.musicuEndpoint = server.URL
|
||||||
|
result, err := client.musicuLyrics("0004jPDk2eB2dt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Lyric != "[00:01.00]原文" || result.Trans != "[00:01.00]translation" {
|
||||||
|
t.Fatalf("unexpected lyrics: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQQClientSearchUsesFixedShape(t *testing.T) {
|
func TestQQClientSearchUsesFixedShape(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Origin") != "https://y.qq.com" || r.Header.Get("Referer") != "https://y.qq.com/" {
|
if r.Header.Get("Origin") != "https://y.qq.com" || r.Header.Get("Referer") != "https://y.qq.com/" {
|
||||||
@@ -44,8 +89,8 @@ func TestQQClientRejectsOversizedResponse(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
client := newQQClient()
|
client := newQQClient()
|
||||||
client.lyricsEndpoint = server.URL
|
client.musicuEndpoint = server.URL
|
||||||
_, err := client.Lyrics("0004jPDk2eB2dt")
|
_, err := client.musicuLyrics("0004jPDk2eB2dt")
|
||||||
if err == nil || !strings.Contains(err.Error(), "safety limit") {
|
if err == nil || !strings.Contains(err.Error(), "safety limit") {
|
||||||
t.Fatalf("expected response limit error, got %v", err)
|
t.Fatalf("expected response limit error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The QRC transform and keys below follow the community-provided
|
||||||
|
// qq_lyric_tables.py implementation supplied with the original script.
|
||||||
|
var (
|
||||||
|
qrcKey1 = [8]byte{'!', '@', '#', ')', '(', '*', '$', '%'}
|
||||||
|
qrcKey2 = [8]byte{'1', '2', '3', 'Z', 'X', 'C', '!', '@'}
|
||||||
|
qrcKey3 = [8]byte{'!', '@', '#', ')', '(', 'N', 'H', 'L'}
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxQRCBytes = 4 << 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
qrcWordTimingPattern = regexp.MustCompile(`\(\d+,\d+\)`)
|
||||||
|
qrcLinePattern = regexp.MustCompile(`^\[(\d+),(\d+)\](.*)$`)
|
||||||
|
qrcMetadataPattern = regexp.MustCompile(`^\[(ti|ar|al|by|offset|kana|language|total):`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func decryptQRCLyric(encoded string) (string, error) {
|
||||||
|
encrypted, err := hex.DecodeString(strings.TrimSpace(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("QQ Music returned invalid phonetic lyrics")
|
||||||
|
}
|
||||||
|
if len(encrypted) == 0 || len(encrypted)%8 != 0 || len(encrypted) > maxUpstreamBytes {
|
||||||
|
return "", errors.New("QQ Music returned invalid phonetic lyric length")
|
||||||
|
}
|
||||||
|
decrypted := append([]byte(nil), encrypted...)
|
||||||
|
transformQRCBlocks(decrypted, newQRCDes(qrcKey3, true))
|
||||||
|
transformQRCBlocks(decrypted, newQRCDes(qrcKey2, false))
|
||||||
|
transformQRCBlocks(decrypted, newQRCDes(qrcKey1, true))
|
||||||
|
|
||||||
|
reader, err := zlib.NewReader(bytes.NewReader(decrypted))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("QQ Music returned invalid compressed phonetic lyrics")
|
||||||
|
}
|
||||||
|
decompressed, readErr := io.ReadAll(io.LimitReader(reader, maxQRCBytes+1))
|
||||||
|
closeErr := reader.Close()
|
||||||
|
if readErr != nil || closeErr != nil {
|
||||||
|
return "", errors.New("could not decompress phonetic lyrics")
|
||||||
|
}
|
||||||
|
if len(decompressed) > maxQRCBytes {
|
||||||
|
return "", errors.New("phonetic lyrics exceeded the safety limit")
|
||||||
|
}
|
||||||
|
return qrcXMLToLRC(decompressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrcXMLToLRC(document []byte) (string, error) {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(document))
|
||||||
|
content := ""
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("QQ Music returned invalid phonetic lyric XML")
|
||||||
|
}
|
||||||
|
start, ok := token.(xml.StartElement)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, attribute := range start.Attr {
|
||||||
|
if attribute.Name.Local == "LyricContent" {
|
||||||
|
content = attribute.Value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return "", errors.New("phonetic lyrics did not contain lyric content")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, strings.Count(content, "\n")+1)
|
||||||
|
for _, rawLine := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if qrcMetadataPattern.MatchString(line) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := qrcLinePattern.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
lines = append(lines, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil || start < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lyric := strings.TrimSpace(qrcWordTimingPattern.ReplaceAllString(match[3], ""))
|
||||||
|
if lyric == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("[%02d:%02d.%03d]%s", start/60000, start%60000/1000, start%1000, lyric))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return "", errors.New("phonetic lyrics were empty")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type qrcDes struct {
|
||||||
|
subkeys [16]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQRCDes(key [8]byte, decrypt bool) qrcDes {
|
||||||
|
parameter := qrcMap64(binary.LittleEndian.Uint64(key[:]), qrcKeyPermutation[:])
|
||||||
|
c, d := uint32(parameter), uint32(parameter>>32)
|
||||||
|
var result qrcDes
|
||||||
|
for round, shift := range qrcRoundShifts {
|
||||||
|
c = c<<shift | (c>>(28-shift))&0xfffffff0
|
||||||
|
d = d<<shift | (d>>(28-shift))&0xfffffff0
|
||||||
|
subkey := qrcMap64(uint64(d)<<32|uint64(c), qrcKeyCompression[:])
|
||||||
|
if decrypt {
|
||||||
|
result.subkeys[15-round] = subkey
|
||||||
|
} else {
|
||||||
|
result.subkeys[round] = subkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d qrcDes) transform(value uint64) uint64 {
|
||||||
|
state := qrcMap64(value, qrcInitialPermutation[:])
|
||||||
|
for _, key := range d.subkeys {
|
||||||
|
right := uint32(state >> 32)
|
||||||
|
left := uint32(state)
|
||||||
|
expanded := qrcMap64(uint64(right)<<32|uint64(right), qrcKeyExpansion[:]) ^ key
|
||||||
|
sbox := uint32(0)
|
||||||
|
for index, shift := range qrcSBoxShifts {
|
||||||
|
sbox = sbox<<4 | uint32(qrcSBoxes[index][expanded>>shift&0x3f])
|
||||||
|
}
|
||||||
|
next := qrcMap32(sbox, qrcPBox[:]) ^ left
|
||||||
|
state = uint64(next)<<32 | uint64(right)
|
||||||
|
}
|
||||||
|
state = state>>32 | state<<32
|
||||||
|
return qrcMap64(state, qrcFinalPermutation[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformQRCBlocks(data []byte, cipher qrcDes) {
|
||||||
|
for offset := 0; offset < len(data); offset += 8 {
|
||||||
|
binary.LittleEndian.PutUint64(data[offset:offset+8], cipher.transform(binary.LittleEndian.Uint64(data[offset:offset+8])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrcBitMask(index uint8) uint64 {
|
||||||
|
if index < 32 {
|
||||||
|
return uint64(1) << (31 - index)
|
||||||
|
}
|
||||||
|
return uint64(1) << (63 - index + 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrcMapBit(result, source uint64, check, set uint8) uint64 {
|
||||||
|
if source&qrcBitMask(check) != 0 {
|
||||||
|
result |= qrcBitMask(set)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrcMap64(source uint64, table []uint8) uint64 {
|
||||||
|
half := len(table) / 2
|
||||||
|
var low, high uint64
|
||||||
|
for index := 0; index < half; index++ {
|
||||||
|
low = qrcMapBit(low, source, table[index], uint8(index))
|
||||||
|
high = qrcMapBit(high, source, table[index+half], uint8(index))
|
||||||
|
}
|
||||||
|
return uint64(uint32(high))<<32 | uint64(uint32(low))
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrcMap32(source uint32, table []uint8) uint32 {
|
||||||
|
var result uint64
|
||||||
|
for index, check := range table {
|
||||||
|
result = qrcMapBit(result, uint64(source), check, uint8(index))
|
||||||
|
}
|
||||||
|
return uint32(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcRoundShifts = [16]uint8{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}
|
||||||
|
var qrcSBoxShifts = [8]uint8{26, 20, 14, 8, 58, 52, 46, 40}
|
||||||
|
|
||||||
|
var qrcInitialPermutation = [64]uint8{
|
||||||
|
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
|
||||||
|
56, 48, 40, 32, 24, 16, 8, 0, 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcFinalPermutation = [64]uint8{
|
||||||
|
39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28,
|
||||||
|
35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25, 32, 0, 40, 8, 48, 16, 56, 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcKeyPermutation = [56]uint8{
|
||||||
|
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35,
|
||||||
|
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcKeyCompression = [48]uint8{
|
||||||
|
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1,
|
||||||
|
45, 56, 35, 41, 51, 59, 34, 44, 55, 49, 37, 52, 48, 53, 43, 60, 38, 57, 50, 46, 54, 40, 33, 36,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcKeyExpansion = [48]uint8{
|
||||||
|
31, 0, 1, 2, 3, 4, 3, 4, 5, 6, 7, 8, 7, 8, 9, 10, 11, 12, 11, 12, 13, 14, 15, 16, 15, 16, 17, 18, 19, 20, 19, 20, 21, 22, 23, 24,
|
||||||
|
23, 24, 25, 26, 27, 28, 27, 28, 29, 30, 31, 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qrcPBox = [32]uint8{15, 6, 19, 20, 28, 11, 27, 16, 0, 14, 22, 25, 4, 17, 30, 9, 1, 7, 23, 13, 31, 26, 2, 8, 18, 12, 29, 5, 21, 10, 3, 24}
|
||||||
|
|
||||||
|
var qrcSBoxes = [8][64]uint8{
|
||||||
|
{14, 0, 4, 15, 13, 7, 1, 4, 2, 14, 15, 2, 11, 13, 8, 1, 3, 10, 10, 6, 6, 12, 12, 11, 5, 9, 9, 5, 0, 3, 7, 8, 4, 15, 1, 12, 14, 8, 8, 2, 13, 4, 6, 9, 2, 1, 11, 7, 15, 5, 12, 11, 9, 3, 7, 14, 3, 10, 10, 0, 5, 6, 0, 13},
|
||||||
|
{15, 3, 1, 13, 8, 4, 14, 7, 6, 15, 11, 2, 3, 8, 4, 15, 9, 12, 7, 0, 2, 1, 13, 10, 12, 6, 0, 9, 5, 11, 10, 5, 0, 13, 14, 8, 7, 10, 11, 1, 10, 3, 4, 15, 13, 4, 1, 2, 5, 11, 8, 6, 12, 7, 6, 12, 9, 0, 3, 5, 2, 14, 15, 9},
|
||||||
|
{10, 13, 0, 7, 9, 0, 14, 9, 6, 3, 3, 4, 15, 6, 5, 10, 1, 2, 13, 8, 12, 5, 7, 14, 11, 12, 4, 11, 2, 15, 8, 1, 13, 1, 6, 10, 4, 13, 9, 0, 8, 6, 15, 9, 3, 8, 0, 7, 11, 4, 1, 15, 2, 14, 12, 3, 5, 11, 10, 5, 14, 2, 7, 12},
|
||||||
|
{7, 13, 13, 8, 14, 11, 3, 5, 0, 6, 6, 15, 9, 0, 10, 3, 1, 4, 2, 7, 8, 2, 5, 12, 11, 1, 12, 10, 4, 14, 15, 9, 10, 3, 6, 15, 9, 0, 0, 6, 12, 10, 11, 10, 7, 13, 13, 8, 15, 9, 1, 4, 3, 5, 14, 11, 5, 12, 2, 7, 8, 2, 4, 14},
|
||||||
|
{2, 14, 12, 11, 4, 2, 1, 12, 7, 4, 10, 7, 11, 13, 6, 1, 8, 5, 5, 0, 3, 15, 15, 10, 13, 3, 0, 9, 14, 8, 9, 6, 4, 11, 2, 8, 1, 12, 11, 7, 10, 1, 13, 14, 7, 2, 8, 13, 15, 6, 9, 15, 12, 0, 5, 9, 6, 10, 3, 4, 0, 5, 14, 3},
|
||||||
|
{12, 10, 1, 15, 10, 4, 15, 2, 9, 7, 2, 12, 6, 9, 8, 5, 0, 6, 13, 1, 3, 13, 4, 14, 14, 0, 7, 11, 5, 3, 11, 8, 9, 4, 14, 3, 15, 2, 5, 12, 2, 9, 8, 5, 12, 15, 3, 10, 7, 11, 0, 14, 4, 1, 10, 7, 1, 6, 13, 0, 11, 8, 6, 13},
|
||||||
|
{4, 13, 11, 0, 2, 11, 14, 7, 15, 4, 0, 9, 8, 1, 13, 10, 3, 14, 12, 3, 9, 5, 7, 12, 5, 2, 10, 15, 6, 8, 1, 6, 1, 6, 4, 11, 11, 13, 13, 8, 12, 1, 3, 4, 7, 10, 14, 7, 10, 9, 15, 5, 6, 0, 8, 15, 0, 14, 5, 2, 9, 3, 2, 12},
|
||||||
|
{13, 1, 2, 15, 8, 13, 4, 8, 6, 10, 15, 3, 11, 7, 1, 4, 10, 12, 9, 5, 3, 6, 14, 11, 5, 0, 0, 14, 12, 9, 7, 2, 7, 2, 11, 1, 4, 14, 1, 7, 9, 4, 12, 10, 14, 8, 2, 13, 0, 15, 6, 12, 10, 9, 13, 0, 15, 3, 3, 5, 5, 6, 8, 11},
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQRCModifiedDESVectors(t *testing.T) {
|
||||||
|
input := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}
|
||||||
|
expected := []byte{
|
||||||
|
0xfd, 0x0e, 0x64, 0x06, 0x65, 0xbe, 0x74, 0x13,
|
||||||
|
0x77, 0x63, 0x3b, 0x02, 0x45, 0x4e, 0x70, 0x7a,
|
||||||
|
}
|
||||||
|
key := [8]byte{'T', 'E', 'S', 'T', '!', 'K', 'E', 'Y'}
|
||||||
|
transformQRCBlocks(input, newQRCDes(key, true))
|
||||||
|
if !bytes.Equal(input, expected) {
|
||||||
|
t.Fatalf("unexpected decrypt vector: %x", input)
|
||||||
|
}
|
||||||
|
transformQRCBlocks(input, newQRCDes(key, false))
|
||||||
|
if !bytes.Equal(input, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}) {
|
||||||
|
t.Fatalf("unexpected encrypt vector: %x", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptQRCAndConvertXML(t *testing.T) {
|
||||||
|
document := []byte(`<QrcInfos><LyricInfo LyricContent="[ti:测试]
[1498,2200]a (1498,200)i (1698,200)
[3849,1200]完整(3849,400)歌词(4249,500)"/></QrcInfos>`)
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
writer := zlib.NewWriter(&compressed)
|
||||||
|
if _, err := writer.Write(document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encrypted := append([]byte(nil), compressed.Bytes()...)
|
||||||
|
if remainder := len(encrypted) % 8; remainder != 0 {
|
||||||
|
encrypted = append(encrypted, make([]byte, 8-remainder)...)
|
||||||
|
}
|
||||||
|
transformQRCBlocks(encrypted, newQRCDes(qrcKey1, false))
|
||||||
|
transformQRCBlocks(encrypted, newQRCDes(qrcKey2, true))
|
||||||
|
transformQRCBlocks(encrypted, newQRCDes(qrcKey3, false))
|
||||||
|
|
||||||
|
result, err := decryptQRCLyric(hex.EncodeToString(encrypted))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "[00:01.498]a i") || !strings.Contains(result, "[00:03.849]完整歌词") {
|
||||||
|
t.Fatalf("unexpected converted lyrics: %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptQRCRejectsInvalidInput(t *testing.T) {
|
||||||
|
if _, err := decryptQRCLyric("not-hex"); err == nil {
|
||||||
|
t.Fatal("expected invalid hex to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := decryptQRCLyric("00"); err == nil {
|
||||||
|
t.Fatal("expected invalid block length to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-1
@@ -15,7 +15,7 @@ func (fakeQQ) Search(keyword string, limit int) ([]song, error) {
|
|||||||
return []song{{Name: keyword, MID: "0004jPDk2eB2dt", Singer: "Tester", Interval: limit}}, nil
|
return []song{{Name: keyword, MID: "0004jPDk2eB2dt", Singer: "Tester", Interval: limit}}, nil
|
||||||
}
|
}
|
||||||
func (fakeQQ) Lyrics(string) (lyricResult, error) {
|
func (fakeQQ) Lyrics(string) (lyricResult, error) {
|
||||||
return lyricResult{Code: 0, Lyric: "[00:01.00]line"}, nil
|
return lyricResult{Code: 0, Lyric: "[00:01.00]line", Trans: "[00:01.00]翻译", Roma: "[00:01.00]sound"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func testApp() (*localApp, http.Handler, fs.FS) {
|
func testApp() (*localApp, http.Handler, fs.FS) {
|
||||||
@@ -103,3 +103,16 @@ func TestRejectsInvalidMID(t *testing.T) {
|
|||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLyricsReturnsAvailableTranslationAndPhonetics(t *testing.T) {
|
||||||
|
_, handler, _ := testApp()
|
||||||
|
r := request(http.MethodPost, "http://127.0.0.1:18765/api/lyrics", `{"mid":"0004jPDk2eB2dt"}`)
|
||||||
|
r.AddCookie(validSessionCookie())
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set("Origin", "http://127.0.0.1:18765")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"trans":"[00:01.00]翻译"`) || !strings.Contains(w.Body.String(), `"roma":"[00:01.00]sound"`) {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-13
@@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<meta name="description" content="搜索并下载 LRC、翻译歌词或纯文本歌词。">
|
<meta name="description" content="搜索、预览并下载原文、翻译与音歌词。" />
|
||||||
<title>LRC Local</title>
|
<title>LRC Local</title>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -14,13 +14,24 @@
|
|||||||
<a href="https://git.sighs.cc/Flechazo/lrc-local">源码</a>
|
<a href="https://git.sighs.cc/Flechazo/lrc-local">源码</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>搜索歌曲并下载 LRC、翻译 LRC 或纯文本歌词。</p>
|
<p>
|
||||||
|
搜索、完整预览并下载原文、翻译或“音”歌词。翻译与音歌词只在 QQ
|
||||||
|
音乐提供对应内容时出现。
|
||||||
|
</p>
|
||||||
<p id="mode-note" class="note">正在确认运行模式…</p>
|
<p id="mode-note" class="note">正在确认运行模式…</p>
|
||||||
|
|
||||||
<form id="search-form">
|
<form id="search-form">
|
||||||
<label for="keyword">歌曲、歌手或专辑</label>
|
<label for="keyword">歌曲、歌手或专辑</label>
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input id="keyword" name="keyword" maxlength="100" autocomplete="off" placeholder="例如:起风了" required autofocus>
|
<input
|
||||||
|
id="keyword"
|
||||||
|
name="keyword"
|
||||||
|
maxlength="100"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="例如:起风了"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
<button type="submit">搜索</button>
|
<button type="submit">搜索</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -29,17 +40,27 @@
|
|||||||
|
|
||||||
<section id="local-downloads" class="downloads" hidden>
|
<section id="local-downloads" class="downloads" hidden>
|
||||||
<h2>可选:本机版</h2>
|
<h2>可选:本机版</h2>
|
||||||
<p>如果不希望查询经过本站,可以下载本机版。它只监听 <code>127.0.0.1</code>,关闭窗口即停止。</p>
|
<p>
|
||||||
|
如果不希望查询经过本站,可以下载本机版。它只监听
|
||||||
|
<code>127.0.0.1</code>,关闭窗口即停止。
|
||||||
|
</p>
|
||||||
<div class="download-links">
|
<div class="download-links">
|
||||||
<a href="/download/lrc-local-windows-amd64.exe">Windows 64 位</a>
|
<a
|
||||||
<a href="/download/lrc-local-linux-amd64.tar.gz">Linux x64</a>
|
href="https://git.sighs.cc/Flechazo/lrc-local-win/releases/download/v2026.08.27-r1/lrc-local-win-x64.exe"
|
||||||
<a href="/download/lrc-local-darwin-arm64.tar.gz">macOS Apple 芯片</a>
|
>Windows 64 位</a
|
||||||
<a href="/download/lrc-local-darwin-amd64.tar.gz">macOS Intel</a>
|
>
|
||||||
<a href="/download/SHA256SUMS">SHA-256</a>
|
<a
|
||||||
|
href="https://git.sighs.cc/Flechazo/lrc-local-win/releases/download/v2026.08.27-r1/SHA256SUMS"
|
||||||
|
>SHA-256</a
|
||||||
|
>
|
||||||
|
<a href="https://git.sighs.cc/Flechazo/lrc-local-win">本机版源码</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>网页版请求由本站转发到固定的 QQ 音乐接口,不保存搜索词、歌词或历史。非 QQ 音乐官方工具,请仅使用你有权访问的内容。</footer>
|
<footer>
|
||||||
|
网页版请求由本站转发到固定的 QQ 音乐接口,不保存搜索词、歌词或历史。非
|
||||||
|
QQ 音乐官方工具,请仅使用你有权访问的内容。
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+242
-30
@@ -5,11 +5,22 @@ const results = document.querySelector('#results');
|
|||||||
const modeNote = document.querySelector('#mode-note');
|
const modeNote = document.querySelector('#mode-note');
|
||||||
const localDownloads = document.querySelector('#local-downloads');
|
const localDownloads = document.querySelector('#local-downloads');
|
||||||
const lyricCache = new Map();
|
const lyricCache = new Map();
|
||||||
|
let publicMode = false;
|
||||||
|
|
||||||
|
const downloadEvents = {
|
||||||
|
lrc: 'original-lrc',
|
||||||
|
txt: 'original-txt',
|
||||||
|
transLRC: 'translation-lrc',
|
||||||
|
transTXT: 'translation-txt',
|
||||||
|
romaLRC: 'sound-lrc',
|
||||||
|
romaTXT: 'sound-txt'
|
||||||
|
};
|
||||||
|
|
||||||
function text(value) { return document.createTextNode(value ?? ''); }
|
function text(value) { return document.createTextNode(value ?? ''); }
|
||||||
|
function hasText(value) { return typeof value === 'string' && value.trim().length > 0; }
|
||||||
function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; }
|
function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; }
|
||||||
function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; }
|
function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; }
|
||||||
function plainText(lrc) { return lrc.split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
|
function plainText(lrc) { return (lrc || '').split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
|
||||||
|
|
||||||
async function api(path, payload) {
|
async function api(path, payload) {
|
||||||
const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
|
const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
|
||||||
@@ -25,6 +36,7 @@ async function loadInfo() {
|
|||||||
if (info.mode === 'local') {
|
if (info.mode === 'local') {
|
||||||
modeNote.textContent = '本机模式:查询从这台电脑直接发出,不经过 lrc.flechazo.xin。';
|
modeNote.textContent = '本机模式:查询从这台电脑直接发出,不经过 lrc.flechazo.xin。';
|
||||||
} else {
|
} else {
|
||||||
|
publicMode = true;
|
||||||
modeNote.textContent = '网页模式:查询由本站后端转发,但不会写入数据库或日志正文。下载文件在浏览器中生成。';
|
modeNote.textContent = '网页模式:查询由本站后端转发,但不会写入数据库或日志正文。下载文件在浏览器中生成。';
|
||||||
localDownloads.hidden = false;
|
localDownloads.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -36,56 +48,256 @@ async function loadInfo() {
|
|||||||
function download(name, body, type = 'text/plain;charset=utf-8') {
|
function download(name, body, type = 'text/plain;charset=utf-8') {
|
||||||
const url = URL.createObjectURL(new Blob([body], {type}));
|
const url = URL.createObjectURL(new Blob([body], {type}));
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = url; anchor.download = name; anchor.hidden = true;
|
anchor.href = url;
|
||||||
document.body.append(anchor); anchor.click(); anchor.remove();
|
anchor.download = name;
|
||||||
|
anchor.hidden = true;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndSave(song, kind, button) {
|
function recordDownload(kind) {
|
||||||
button.disabled = true;
|
const event = downloadEvents[kind];
|
||||||
notice.className = 'notice'; notice.textContent = `正在读取《${song.name}》…`;
|
if (!publicMode || !event) return;
|
||||||
try {
|
fetch(`/api/events/download/${event}`, {method: 'POST', keepalive: true}).catch(() => {});
|
||||||
if (!lyricCache.has(song.mid)) lyricCache.set(song.mid, api('/api/lyrics', {mid: song.mid}));
|
|
||||||
const data = await lyricCache.get(song.mid).catch(error => { lyricCache.delete(song.mid); throw error; });
|
|
||||||
const base = safeName(`${song.name} - ${song.singer}`);
|
|
||||||
if (kind === 'lrc') download(`${base}.lrc`, data.lyric);
|
|
||||||
if (kind === 'txt') download(`${base}.txt`, plainText(data.lyric));
|
|
||||||
if (kind === 'trans') {
|
|
||||||
if (!data.trans) throw new Error('这首歌没有翻译歌词');
|
|
||||||
download(`${base}.trans.lrc`, data.trans);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLRC(lrc) {
|
||||||
|
const lines = [];
|
||||||
|
for (const raw of (lrc || '').split(/\r?\n/)) {
|
||||||
|
const stamps = [...raw.matchAll(/\[(\d+):(\d+)(?:[.:](\d{1,3}))?\]/g)];
|
||||||
|
if (!stamps.length) continue;
|
||||||
|
const content = raw.replace(/\[[^\]]*\]/g, '').trim();
|
||||||
|
if (!content) continue;
|
||||||
|
for (const stamp of stamps) {
|
||||||
|
const fraction = stamp[3] || '0';
|
||||||
|
const milliseconds = Number(fraction.padEnd(3, '0').slice(0, 3));
|
||||||
|
lines.push({time: Number(stamp[1]) * 60000 + Number(stamp[2]) * 1000 + milliseconds, content});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(milliseconds) {
|
||||||
|
const minutes = Math.floor(milliseconds / 60000);
|
||||||
|
const seconds = Math.floor(milliseconds % 60000 / 1000);
|
||||||
|
const millis = milliseconds % 1000;
|
||||||
|
return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignTrack(base, extra) {
|
||||||
|
const aligned = new Array(base.length).fill('');
|
||||||
|
let cursor = 0;
|
||||||
|
for (let index = 0; index < base.length && cursor < extra.length; index += 1) {
|
||||||
|
while (cursor + 1 < extra.length && Math.abs(extra[cursor + 1].time - base[index].time) <= Math.abs(extra[cursor].time - base[index].time)) cursor += 1;
|
||||||
|
if (Math.abs(extra[cursor].time - base[index].time) <= 1800) {
|
||||||
|
aligned[index] = extra[cursor].content;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aligned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(data, mode) {
|
||||||
|
const original = parseLRC(data.lyric);
|
||||||
|
if (!original.length) return data.lyric || '';
|
||||||
|
const translations = mode.includes('trans') ? alignTrack(original, parseLRC(data.trans)) : [];
|
||||||
|
const phonetics = mode.includes('roma') ? alignTrack(original, parseLRC(data.roma)) : [];
|
||||||
|
const output = [];
|
||||||
|
original.forEach((line, index) => {
|
||||||
|
output.push(`${timestamp(line.time)}${line.content}`);
|
||||||
|
if (translations[index]) output.push(` 译:${translations[index]}`);
|
||||||
|
if (phonetics[index]) output.push(` 音:${phonetics[index]}`);
|
||||||
|
});
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedLyrics(song) {
|
||||||
|
if (!lyricCache.has(song.mid)) {
|
||||||
|
const request = api('/api/lyrics', {mid: song.mid}).catch(error => {
|
||||||
|
lyricCache.delete(song.mid);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
lyricCache.set(song.mid, request);
|
||||||
|
}
|
||||||
|
return lyricCache.get(song.mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButton(label, primary, handler) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
if (primary) button.className = 'primary';
|
||||||
|
button.append(text(label));
|
||||||
|
button.addEventListener('click', handler);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLyrics(song, data, kind) {
|
||||||
|
const base = safeName(`${song.name} - ${song.singer}`);
|
||||||
|
const variants = {
|
||||||
|
lrc: [`${base}.lrc`, data.lyric],
|
||||||
|
txt: [`${base}.txt`, plainText(data.lyric)],
|
||||||
|
transLRC: [`${base}.trans.lrc`, data.trans],
|
||||||
|
transTXT: [`${base}.trans.txt`, plainText(data.trans)],
|
||||||
|
romaLRC: [`${base}.sound.lrc`, data.roma],
|
||||||
|
romaTXT: [`${base}.sound.txt`, plainText(data.roma)]
|
||||||
|
};
|
||||||
|
const selected = variants[kind];
|
||||||
|
if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词');
|
||||||
|
download(selected[0], selected[1]);
|
||||||
|
recordDownload(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function populatePreview(select, output, data) {
|
||||||
|
const current = select.value;
|
||||||
|
select.replaceChildren();
|
||||||
|
const choices = [['original', '仅原文']];
|
||||||
|
if (hasText(data.trans)) choices.push(['trans', '原文 + 翻译']);
|
||||||
|
if (hasText(data.roma)) choices.push(['roma', '原文 + 音']);
|
||||||
|
if (hasText(data.trans) && hasText(data.roma)) choices.push(['trans-roma', '原文 + 翻译 + 音']);
|
||||||
|
choices.forEach(([value, label]) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = value;
|
||||||
|
option.append(text(label));
|
||||||
|
select.append(option);
|
||||||
|
});
|
||||||
|
if ([...select.options].some(option => option.value === current)) select.value = current;
|
||||||
|
output.textContent = previewText(data, select.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSongActions(song, actions, data) {
|
||||||
|
actions.replaceChildren();
|
||||||
|
const definitions = [['lrc', '下载 LRC', true], ['txt', '原文 TXT', false]];
|
||||||
|
if (hasText(data.trans)) definitions.push(['transLRC', '翻译 LRC', false], ['transTXT', '翻译 TXT', false]);
|
||||||
|
if (hasText(data.roma)) definitions.push(['romaLRC', '音 LRC', false], ['romaTXT', '音 TXT', false]);
|
||||||
|
definitions.forEach(([kind, label, primary]) => {
|
||||||
|
actions.append(actionButton(label, primary, () => {
|
||||||
|
try {
|
||||||
|
saveLyrics(song, data, kind);
|
||||||
|
notice.className = 'notice';
|
||||||
notice.textContent = '文件已在浏览器中生成。';
|
notice.textContent = '文件已在浏览器中生成。';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notice.className = 'notice error'; notice.textContent = error.message;
|
notice.className = 'notice error';
|
||||||
} finally { button.disabled = false; }
|
notice.textContent = error.message;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSongs(songs) {
|
function renderSongs(songs) {
|
||||||
results.replaceChildren();
|
results.replaceChildren();
|
||||||
songs.forEach((song, index) => {
|
songs.forEach((song, index) => {
|
||||||
const article = document.createElement('article'); article.className = 'song';
|
const article = document.createElement('article');
|
||||||
|
article.className = 'song';
|
||||||
|
const top = document.createElement('div');
|
||||||
|
top.className = 'song-top';
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
const title = document.createElement('h2'); title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`));
|
info.className = 'song-info';
|
||||||
const meta = document.createElement('div'); meta.className = 'meta';
|
const title = document.createElement('h2');
|
||||||
|
title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`));
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'meta';
|
||||||
meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · ')));
|
meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · ')));
|
||||||
info.append(title, meta);
|
info.append(title, meta);
|
||||||
const actions = document.createElement('div'); actions.className = 'actions';
|
|
||||||
[['lrc','下载 LRC','primary'],['txt','纯文本'],['trans','翻译 LRC']].forEach(([kind,label,klass]) => {
|
const actions = document.createElement('div');
|
||||||
const button = document.createElement('button'); button.type = 'button'; button.className = klass || ''; button.append(text(label));
|
actions.className = 'actions';
|
||||||
button.addEventListener('click', () => fetchAndSave(song, kind, button)); actions.append(button);
|
const details = document.createElement('details');
|
||||||
|
details.className = 'preview';
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.append(text('完整歌词预览'));
|
||||||
|
const previewBody = document.createElement('div');
|
||||||
|
previewBody.className = 'preview-body';
|
||||||
|
const previewStatus = document.createElement('p');
|
||||||
|
previewStatus.className = 'preview-status';
|
||||||
|
previewStatus.append(text('展开后读取歌词。'));
|
||||||
|
const controls = document.createElement('label');
|
||||||
|
controls.className = 'preview-controls';
|
||||||
|
controls.hidden = true;
|
||||||
|
controls.append(text('预览内容 '));
|
||||||
|
const select = document.createElement('select');
|
||||||
|
controls.append(select);
|
||||||
|
const output = document.createElement('pre');
|
||||||
|
output.className = 'lyric-preview';
|
||||||
|
output.hidden = true;
|
||||||
|
previewBody.append(previewStatus, controls, output);
|
||||||
|
details.append(summary, previewBody);
|
||||||
|
|
||||||
|
let loadedData = null;
|
||||||
|
const load = async trigger => {
|
||||||
|
if (loadedData) return loadedData;
|
||||||
|
if (trigger) trigger.disabled = true;
|
||||||
|
previewStatus.textContent = `正在读取《${song.name}》…`;
|
||||||
|
notice.className = 'notice';
|
||||||
|
notice.textContent = `正在读取《${song.name}》…`;
|
||||||
|
try {
|
||||||
|
loadedData = await cachedLyrics(song);
|
||||||
|
renderSongActions(song, actions, loadedData);
|
||||||
|
populatePreview(select, output, loadedData);
|
||||||
|
controls.hidden = false;
|
||||||
|
output.hidden = false;
|
||||||
|
previewStatus.hidden = true;
|
||||||
|
notice.textContent = '歌词已读取;翻译和“音”按钮只在存在对应内容时显示。';
|
||||||
|
return loadedData;
|
||||||
|
} catch (error) {
|
||||||
|
previewStatus.hidden = false;
|
||||||
|
previewStatus.textContent = error.message;
|
||||||
|
notice.className = 'notice error';
|
||||||
|
notice.textContent = error.message;
|
||||||
|
if (details.open) details.open = false;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (trigger) trigger.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialLRC = actionButton('下载 LRC', true, async () => {
|
||||||
|
try {
|
||||||
|
const data = await load(initialLRC);
|
||||||
|
saveLyrics(song, data, 'lrc');
|
||||||
|
notice.textContent = '文件已在浏览器中生成。';
|
||||||
|
} catch { /* load already reports the error */ }
|
||||||
});
|
});
|
||||||
article.append(info, actions); results.append(article);
|
const initialTXT = actionButton('原文 TXT', false, async () => {
|
||||||
|
try {
|
||||||
|
const data = await load(initialTXT);
|
||||||
|
saveLyrics(song, data, 'txt');
|
||||||
|
notice.textContent = '文件已在浏览器中生成。';
|
||||||
|
} catch { /* load already reports the error */ }
|
||||||
|
});
|
||||||
|
actions.append(initialLRC, initialTXT);
|
||||||
|
details.addEventListener('toggle', () => {
|
||||||
|
if (details.open && !loadedData) load().catch(() => {});
|
||||||
|
});
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
if (loadedData) output.textContent = previewText(loadedData, select.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
top.append(info, actions);
|
||||||
|
article.append(top, details);
|
||||||
|
results.append(article);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', async event => {
|
form.addEventListener('submit', async event => {
|
||||||
event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true;
|
event.preventDefault();
|
||||||
notice.className = 'notice'; notice.textContent = '正在搜索…'; results.replaceChildren(); lyricCache.clear();
|
const submit = form.querySelector('button');
|
||||||
|
submit.disabled = true;
|
||||||
|
notice.className = 'notice';
|
||||||
|
notice.textContent = '正在搜索…';
|
||||||
|
results.replaceChildren();
|
||||||
|
lyricCache.clear();
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15});
|
const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15});
|
||||||
renderSongs(data.songs || []); notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。` : '没有找到相关歌曲。';
|
renderSongs(data.songs || []);
|
||||||
} catch (error) { notice.className = 'notice error'; notice.textContent = error.message; }
|
notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。展开预览或下载时才会读取歌词。` : '没有找到相关歌曲。';
|
||||||
finally { submit.disabled = false; }
|
} catch (error) {
|
||||||
|
notice.className = 'notice error';
|
||||||
|
notice.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loadInfo();
|
loadInfo();
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(820px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:650px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}}
|
:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(900px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus,select:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px;min-width:0}.song-top{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center}.song-info{min-width:0}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.preview{margin-top:12px;border-top:1px solid #eaecf0;padding-top:10px}.preview summary{color:#155eef;cursor:pointer;font-size:.9rem;width:max-content}.preview-body{padding-top:10px}.preview-status{color:#667085;font-size:.86rem;margin:0}.preview-controls{font-size:.86rem;margin:0 0 8px}.preview-controls select{border:1px solid #b8c0cc;border-radius:6px;background:#fff;color:#1f2937;padding:6px 28px 6px 8px;font:inherit}.lyric-preview{width:100%;max-height:min(62vh,720px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;border:1px solid #dfe3e8;border-radius:7px;background:#f8fafc;color:#344054;font:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;font-size:.82rem;line-height:1.65}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:700px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song-top{grid-template-columns:1fr}.actions{justify-content:flex-start}.actions button{flex:1 1 auto}.preview summary{width:auto}.lyric-preview{max-height:58vh;padding:10px;font-size:.78rem}}
|
||||||
|
|||||||
Reference in New Issue
Block a user