Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d814d6636 | ||
|
|
6e9f0ac648 |
+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,6 +1,6 @@
|
|||||||
# LRC Local
|
# LRC Local
|
||||||
|
|
||||||
一个可直接在网页使用、也提供可选本机版的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。
|
`lrc.flechazo.xin` 的 Go 网页服务。
|
||||||
|
|
||||||
网页:<https://lrc.flechazo.xin>
|
网页:<https://lrc.flechazo.xin>
|
||||||
|
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
- 导出原文 LRC/TXT;有真实翻译时才提供翻译 LRC/TXT,有罗马音、韩语音译或方言注音时才提供“音” LRC/TXT。
|
- 导出原文 LRC/TXT;有真实翻译时才提供翻译 LRC/TXT,有罗马音、韩语音译或方言注音时才提供“音” LRC/TXT。
|
||||||
- 歌词来自 QQ 音乐 `GetPlayLyricInfo` 返回的原文、翻译与 `roma` 字段;本站不进行机器翻译,也不自行生成注音。
|
- 歌词来自 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.26-r6
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-44
@@ -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
|
||||||
@@ -34,25 +24,24 @@ 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{}
|
mu sync.Mutex
|
||||||
mu sync.Mutex
|
global rateWindow
|
||||||
global rateWindow
|
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,7 +57,6 @@ 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"})
|
||||||
})
|
})
|
||||||
@@ -106,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"))
|
||||||
|
|||||||
+11
-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,7 +31,7 @@ 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" {
|
||||||
@@ -45,7 +43,7 @@ func TestPublicPageNeedsNoSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -56,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(), "测试") {
|
||||||
@@ -65,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}`))
|
||||||
@@ -78,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")
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-39
@@ -1,46 +1,67 @@
|
|||||||
<!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="搜索、预览并下载原文、翻译与音歌词。">
|
<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>
|
||||||
<header>
|
<header>
|
||||||
<h1>LRC Local</h1>
|
<h1>LRC Local</h1>
|
||||||
<a href="https://git.sighs.cc/Flechazo/lrc-local">源码</a>
|
<a href="https://git.sighs.cc/Flechazo/lrc-local">源码</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>搜索、完整预览并下载原文、翻译或“音”歌词。翻译与音歌词只在 QQ 音乐提供对应内容时出现。</p>
|
<p>
|
||||||
<p id="mode-note" class="note">正在确认运行模式…</p>
|
搜索、完整预览并下载原文、翻译或“音”歌词。翻译与音歌词只在 QQ
|
||||||
|
音乐提供对应内容时出现。
|
||||||
|
</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
|
||||||
<button type="submit">搜索</button>
|
id="keyword"
|
||||||
</div>
|
name="keyword"
|
||||||
</form>
|
maxlength="100"
|
||||||
<div id="notice" class="notice" role="status" aria-live="polite"></div>
|
autocomplete="off"
|
||||||
<section id="results" class="results" aria-label="搜索结果"></section>
|
placeholder="例如:起风了"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit">搜索</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="notice" class="notice" role="status" aria-live="polite"></div>
|
||||||
|
<section id="results" class="results" aria-label="搜索结果"></section>
|
||||||
|
|
||||||
<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>
|
||||||
<div class="download-links">
|
如果不希望查询经过本站,可以下载本机版。它只监听
|
||||||
<a href="/download/lrc-local-windows-amd64.exe">Windows 64 位</a>
|
<code>127.0.0.1</code>,关闭窗口即停止。
|
||||||
<a href="/download/lrc-local-linux-amd64.tar.gz">Linux x64</a>
|
</p>
|
||||||
<a href="/download/lrc-local-darwin-arm64.tar.gz">macOS Apple 芯片</a>
|
<div class="download-links">
|
||||||
<a href="/download/lrc-local-darwin-amd64.tar.gz">macOS Intel</a>
|
<a
|
||||||
<a href="/download/SHA256SUMS">SHA-256</a>
|
href="https://git.sighs.cc/Flechazo/lrc-local-win/releases/download/v2026.08.27-r1/lrc-local-win-x64.exe"
|
||||||
</div>
|
>Windows 64 位</a
|
||||||
</section>
|
>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer>网页版请求由本站转发到固定的 QQ 音乐接口,不保存搜索词、歌词或历史。非 QQ 音乐官方工具,请仅使用你有权访问的内容。</footer>
|
<footer>
|
||||||
</main>
|
网页版请求由本站转发到固定的 QQ 音乐接口,不保存搜索词、歌词或历史。非
|
||||||
<script src="/app.js" defer></script>
|
QQ 音乐官方工具,请仅使用你有权访问的内容。
|
||||||
</body>
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+18
@@ -5,6 +5,16 @@ 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 hasText(value) { return typeof value === 'string' && value.trim().length > 0; }
|
||||||
@@ -26,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;
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,12 @@ function download(name, body, type = 'text/plain;charset=utf-8') {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordDownload(kind) {
|
||||||
|
const event = downloadEvents[kind];
|
||||||
|
if (!publicMode || !event) return;
|
||||||
|
fetch(`/api/events/download/${event}`, {method: 'POST', keepalive: true}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function parseLRC(lrc) {
|
function parseLRC(lrc) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
for (const raw of (lrc || '').split(/\r?\n/)) {
|
for (const raw of (lrc || '').split(/\r?\n/)) {
|
||||||
@@ -129,6 +146,7 @@ function saveLyrics(song, data, kind) {
|
|||||||
const selected = variants[kind];
|
const selected = variants[kind];
|
||||||
if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词');
|
if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词');
|
||||||
download(selected[0], selected[1]);
|
download(selected[0], selected[1]);
|
||||||
|
recordDownload(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populatePreview(select, output, data) {
|
function populatePreview(select, output, data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user