feat: launch privacy-first local lyrics tool
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
* text=auto eol=lf
|
||||
*.go text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
*.yml text eol=lf
|
||||
@@ -0,0 +1,3 @@
|
||||
/dist/
|
||||
*.exe
|
||||
*.test
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
FROM golang:1.26.2-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS builder
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY *.go ./
|
||||
COPY web ./web
|
||||
RUN mkdir -p /out/download \
|
||||
&& 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 . \
|
||||
&& 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 busybox:1.37.0-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23
|
||||
COPY public /srv
|
||||
COPY --from=builder /out/download /srv/download
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["httpd", "-f", "-p", "8080", "-h", "/srv"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Flechazo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
# LRC Local
|
||||
|
||||
一个本地优先的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。
|
||||
|
||||
公开下载页:<https://lrc.flechazo.xin>
|
||||
|
||||
## 为什么不是纯网页
|
||||
|
||||
QQ 音乐接口拒绝第三方网页的跨域请求,并校验来源。浏览器页面无法安全地修改 `Origin` 和 `Referer`。LRC Local 因此以本机单文件程序运行:程序只监听随机的 `127.0.0.1` 端口,浏览器 UI 和外部请求均由用户自己的电脑处理;公开服务器只分发静态页面和二进制文件。
|
||||
|
||||
## 功能
|
||||
|
||||
- 搜索歌曲并显示歌手、专辑和时长。
|
||||
- 导出原文 `.lrc`、翻译 `.trans.lrc` 或无时间轴 `.txt`。
|
||||
- 不创建账号,不保存搜索历史,不向本站服务器上传任何内容。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 只监听操作系统分配的随机 IPv4 回环端口。
|
||||
- 启动使用 256 位随机令牌,之后使用 `HttpOnly`、`SameSite=Strict` 会话 Cookie。
|
||||
- 校验精确的 `Host` 和 `Origin`,不设置 CORS,拒绝 DNS rebinding 和跨站 API 请求。
|
||||
- 只连接源码中固定的两个 HTTPS 接口;禁止重定向,不提供通用代理。
|
||||
- 限制请求体、上游响应体、并发、频率和超时。
|
||||
- 页面启用严格 CSP、禁止嵌入、禁止外部脚本和外部资源。
|
||||
- 所有公开构建均提供 SHA-256 校验值;Linux 和 macOS 使用归档保留可执行权限。
|
||||
|
||||
## 本地构建与测试
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -trimpath .
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
./lrc-local
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
生产变更必须先提交并推送到自建 Gitea,再由生产机检出明确提交。`docker compose up -d --build` 会从同一提交交叉编译四个平台的程序,并以无特权、只读容器提供静态下载页。Caddy 只反向代理 `127.0.0.1:8083`。
|
||||
|
||||
## 声明
|
||||
|
||||
本项目不是 QQ 音乐官方产品,QQ 音乐是其权利人的商标。使用者应遵守相关服务条款与著作权规则,只访问和使用自己有权获取的内容。
|
||||
@@ -0,0 +1,27 @@
|
||||
name: lrc-local
|
||||
|
||||
services:
|
||||
site:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: v2026.08.25-r1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8083:8080"
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 32
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var embeddedWeb embed.FS
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
noOpen := flag.Bool("no-open", false, "do not open the browser automatically")
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
webFS, err := fs.Sub(embeddedWeb, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
baseURL := "http://" + listener.Addr().String()
|
||||
app := newApp(baseURL, token, webFS, newQQClient())
|
||||
server := &http.Server{
|
||||
Handler: app.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 16 * 1024,
|
||||
}
|
||||
|
||||
launchURL := baseURL + "/start?t=" + token
|
||||
fmt.Printf("LRC Local %s\n仅监听本机:%s\n关闭此窗口即可停止。\n", version, baseURL)
|
||||
if !*noOpen {
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if err := openBrowser(launchURL); err != nil {
|
||||
log.Printf("无法自动打开浏览器,请手动访问:%s", launchURL)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
fmt.Printf("请访问:%s\n", launchURL)
|
||||
}
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var command *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
case "darwin":
|
||||
command = exec.Command("open", url)
|
||||
default:
|
||||
command = exec.Command("xdg-open", url)
|
||||
}
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
return command.Start()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="LRC Local:在你自己的电脑上搜索并导出 LRC 歌词。">
|
||||
<title>LRC Local — 歌词只经过你的电脑</title>
|
||||
<link rel="stylesheet" href="/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav><b>LRC <i>Local</i></b><a href="https://git.sighs.cc/Flechazo/lrc-local">查看源码 ↗</a></nav>
|
||||
<section class="hero">
|
||||
<div class="copy"><p class="tag">NO CLOUD · NO ACCOUNT · NO HISTORY</p><h1>歌词只经过<br><em>你的电脑。</em></h1><p class="lead">一个很小的本地歌词工具。下载、运行,浏览器会自动打开;搜索词、歌词和生成的文件都不会经过本站服务器。</p></div>
|
||||
<aside><span>01</span><h2>下载本机程序</h2><p>无需安装。首次运行时,系统可能提示未知发布者。</p><a class="primary" href="/download/lrc-local-windows-amd64.exe">Windows 64 位</a><div class="secondary"><a href="/download/lrc-local-linux-amd64.tar.gz">Linux x64</a><a href="/download/lrc-local-darwin-arm64.tar.gz">macOS Apple 芯片</a><a href="/download/lrc-local-darwin-amd64.tar.gz">macOS Intel</a></div></aside>
|
||||
</section>
|
||||
<section class="steps"><article><b>02</b><h3>双击运行</h3><p>程序只监听随机的本机回环端口,不对局域网或公网开放。</p></article><article><b>03</b><h3>搜索与导出</h3><p>支持原文 LRC、翻译 LRC 和去除时间轴的 TXT。</p></article><article><b>04</b><h3>关掉即停止</h3><p>关闭程序窗口,本地服务立即消失;不创建账号,不保存历史。</p></article></section>
|
||||
<section class="trust"><h2>它到底把什么发到哪里?</h2><p>本站只提供静态页面和程序下载。运行后的程序只向固定的 QQ 音乐搜索与歌词接口发出请求,不接受任意代理地址。浏览器只连接 <code>127.0.0.1</code>,文件由浏览器在本地生成。</p><p class="small">非 QQ 音乐官方工具。QQ 音乐是其权利人的商标。请遵守服务条款和著作权规则,只使用你有权访问的内容。校验文件:<a href="/download/SHA256SUMS">SHA256SUMS</a></p></section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,"Segoe UI","PingFang SC",sans-serif;background:#08100d;color:#edf6f2;color-scheme:dark;--mint:#75f0b2;--muted:#94a49e;--line:#27352f}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 87% 5%,#185036 0,transparent 34rem),#08100d}main{width:min(1160px,calc(100% - 34px));margin:auto}nav{height:86px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}nav b{letter-spacing:.08em}nav i{font-style:normal;color:var(--mint)}a{color:inherit}.hero{min-height:650px;display:grid;grid-template-columns:minmax(0,1.45fr) minmax(310px,.55fr);gap:70px;align-items:center}.tag{color:var(--mint);font:700 .74rem ui-monospace,monospace;letter-spacing:.18em}.copy h1{font-size:clamp(3.8rem,8.2vw,7.4rem);line-height:.88;letter-spacing:-.07em;margin:28px 0}.copy h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px #a9c9bc}.lead{max-width:690px;color:var(--muted);font-size:1.1rem;line-height:1.8}aside{border:1px solid #3a554a;background:#10211a;padding:28px;border-radius:14px;box-shadow:0 24px 70px #0008}aside>span,.steps b{color:var(--mint);font:700 .78rem ui-monospace,monospace}aside h2{font-size:1.7rem;margin:18px 0 8px}aside p,.steps p,.trust p{color:var(--muted);line-height:1.7}.primary{display:block;text-align:center;background:var(--mint);color:#07110c;text-decoration:none;font-weight:850;border-radius:8px;padding:15px;margin:25px 0 12px}.secondary{display:grid;gap:7px}.secondary a{text-align:center;border:1px solid #365046;border-radius:7px;padding:10px;text-decoration:none;font-size:.84rem}.steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.steps article{padding:36px 30px;border-right:1px solid var(--line)}.steps article:last-child{border:0}.steps h3{font-size:1.25rem}.trust{padding:80px 0;max-width:850px}.trust h2{font-size:2.4rem;letter-spacing:-.035em}.small{font-size:.82rem}code{background:#15231e;padding:2px 5px;border-radius:4px;color:#c6e3d7}@media(max-width:800px){.hero{grid-template-columns:1fr;padding:65px 0}.copy h1{font-size:4rem}.steps{grid-template-columns:1fr}.steps article{border-right:0;border-bottom:1px solid var(--line)}}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
maxUpstreamBytes = 4 << 20
|
||||
)
|
||||
|
||||
type song struct {
|
||||
Name string `json:"name"`
|
||||
MID string `json:"mid"`
|
||||
Singer string `json:"singer"`
|
||||
Album string `json:"album"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type lyricResult struct {
|
||||
Lyric string `json:"lyric"`
|
||||
Trans string `json:"trans"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type qqAPI interface {
|
||||
Search(string, int) ([]song, error)
|
||||
Lyrics(string) (lyricResult, error)
|
||||
}
|
||||
|
||||
type qqClient struct {
|
||||
httpClient *http.Client
|
||||
searchEndpoint string
|
||||
lyricsEndpoint string
|
||||
}
|
||||
|
||||
func newQQClient() *qqClient {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.MaxIdleConns = 8
|
||||
transport.MaxIdleConnsPerHost = 4
|
||||
transport.ResponseHeaderTimeout = 10 * time.Second
|
||||
return &qqClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("unexpected redirect from fixed QQ Music endpoint")
|
||||
},
|
||||
},
|
||||
searchEndpoint: searchEndpoint,
|
||||
lyricsEndpoint: lyricsEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *qqClient) Search(keyword string, limit int) ([]song, error) {
|
||||
query := url.Values{
|
||||
"format": {"json"},
|
||||
"p": {"1"},
|
||||
"n": {fmt.Sprint(limit)},
|
||||
"w": {keyword},
|
||||
"outCharset": {"utf-8"},
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
SongName string `json:"songname"`
|
||||
SongMID string `json:"songmid"`
|
||||
AlbumName string `json:"albumname"`
|
||||
Interval int `json:"interval"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.getJSON(c.searchEndpoint, query, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Code != 0 {
|
||||
return nil, fmt.Errorf("QQ Music search failed: %s", response.Message)
|
||||
}
|
||||
result := make([]song, 0, len(response.Data.Song.List))
|
||||
for _, item := range response.Data.Song.List {
|
||||
if item.SongMID == "" {
|
||||
continue
|
||||
}
|
||||
names := make([]string, 0, len(item.Singer))
|
||||
for _, singer := range item.Singer {
|
||||
if singer.Name != "" {
|
||||
names = append(names, singer.Name)
|
||||
}
|
||||
}
|
||||
result = append(result, song{
|
||||
Name: item.SongName, MID: item.SongMID,
|
||||
Singer: strings.Join(names, " / "), Album: item.AlbumName, Interval: item.Interval,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *qqClient) Lyrics(mid string) (lyricResult, error) {
|
||||
query := url.Values{
|
||||
"format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"},
|
||||
}
|
||||
var result lyricResult
|
||||
if err := c.getJSON(c.lyricsEndpoint, query, &result); err != nil {
|
||||
return lyricResult{}, err
|
||||
}
|
||||
if result.Code != 0 || result.Lyric == "" {
|
||||
return lyricResult{}, errors.New("lyrics are not available for this track")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error {
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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("Origin", "https://y.qq.com")
|
||||
request.Header.Set("Accept", "application/json,text/plain,*/*")
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("QQ Music request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("QQ Music returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > maxUpstreamBytes {
|
||||
return errors.New("QQ Music response exceeded the safety limit")
|
||||
}
|
||||
body = unwrapJSONP(body)
|
||||
if err := json.Unmarshal(body, destination); err != nil {
|
||||
return errors.New("QQ Music returned an invalid response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unwrapJSONP(body []byte) []byte {
|
||||
body = bytes.TrimSpace(body)
|
||||
if len(body) == 0 || body[0] == '{' || body[0] == '[' {
|
||||
return body
|
||||
}
|
||||
left := bytes.IndexByte(body, '(')
|
||||
right := bytes.LastIndexByte(body, ')')
|
||||
if left >= 0 && right > left {
|
||||
return bytes.TrimSpace(body[left+1 : right])
|
||||
}
|
||||
return body
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnwrapJSONP(t *testing.T) {
|
||||
got := unwrapJSONP([]byte(" callback_1( {\"code\":0} ); "))
|
||||
if string(got) != `{"code":0}` {
|
||||
t.Fatalf("unexpected JSONP body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQQClientSearchUsesFixedShape(t *testing.T) {
|
||||
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/" {
|
||||
t.Fatal("required upstream headers were not set")
|
||||
}
|
||||
if r.URL.Query().Get("w") != "起风了" || r.URL.Query().Get("n") != "2" {
|
||||
t.Fatalf("unexpected query: %s", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"song":{"list":[{"songname":"歌","songmid":"0004jPDk2eB2dt","albumname":"专辑","interval":215,"singer":[{"name":"歌手"}]}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newQQClient()
|
||||
client.searchEndpoint = server.URL
|
||||
songs, err := client.Search("起风了", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(songs) != 1 || songs[0].MID != "0004jPDk2eB2dt" || songs[0].Singer != "歌手" {
|
||||
t.Fatalf("unexpected songs: %#v", songs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQQClientRejectsOversizedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(bytes.Repeat([]byte("x"), maxUpstreamBytes+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newQQClient()
|
||||
client.lyricsEndpoint = server.URL
|
||||
_, err := client.Lyrics("0004jPDk2eB2dt")
|
||||
if err == nil || !strings.Contains(err.Error(), "safety limit") {
|
||||
t.Fatalf("expected response limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sessionCookie = "lrc_local_session"
|
||||
|
||||
var songMIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{8,64}$`)
|
||||
|
||||
type localApp struct {
|
||||
baseURL string
|
||||
host string
|
||||
token string
|
||||
web fs.FS
|
||||
qq qqAPI
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
window time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
func newApp(baseURL, token string, web fs.FS, qq qqAPI) *localApp {
|
||||
parsed, _ := url.Parse(baseURL)
|
||||
return &localApp{
|
||||
baseURL: baseURL, host: parsed.Host, token: token, web: web, qq: qq,
|
||||
sem: make(chan struct{}, 4), window: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *localApp) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /start", a.start)
|
||||
mux.HandleFunc("GET /app", a.withSession(a.appPage))
|
||||
mux.HandleFunc("GET /app.js", a.withSession(a.asset("app.js", "text/javascript; charset=utf-8")))
|
||||
mux.HandleFunc("GET /styles.css", a.withSession(a.asset("styles.css", "text/css; charset=utf-8")))
|
||||
mux.HandleFunc("POST /api/search", a.withSession(a.withAPI(a.search)))
|
||||
mux.HandleFunc("POST /api/lyrics", a.withSession(a.withAPI(a.lyrics)))
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
return a.securityHeaders(a.validateHost(mux))
|
||||
}
|
||||
|
||||
func (a *localApp) validateHost(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != a.host || r.RemoteAddr == "" {
|
||||
http.Error(w, "invalid local request", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *localApp) 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("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *localApp) start(w http.ResponseWriter, r *http.Request) {
|
||||
if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("t")), []byte(a.token)) != 1 {
|
||||
http.Error(w, "invalid launch token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: a.token, Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: 12 * 60 * 60,
|
||||
})
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *localApp) withSession(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookie)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(a.token)) != 1 {
|
||||
http.Error(w, "please launch LRC Local again", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *localApp) withAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Origin") != a.baseURL || !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "invalid local origin"})
|
||||
return
|
||||
}
|
||||
if !a.allowRequest() {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "请求过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case a.sem <- struct{}{}:
|
||||
defer func() { <-a.sem }()
|
||||
case <-time.After(time.Second):
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "请求较多,请稍后重试"})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *localApp) allowRequest() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if now.Sub(a.window) >= time.Minute {
|
||||
a.window, a.count = now, 0
|
||||
}
|
||||
if a.count >= 60 {
|
||||
return false
|
||||
}
|
||||
a.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *localApp) appPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.serveFile(w, r, "app.html", "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
func (a *localApp) asset(name, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) { a.serveFile(w, r, name, contentType) }
|
||||
}
|
||||
|
||||
func (a *localApp) serveFile(w http.ResponseWriter, r *http.Request, name, contentType string) {
|
||||
data, err := fs.ReadFile(a.web, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (a *localApp) search(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := decodeOneJSON(r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
input.Keyword = strings.TrimSpace(input.Keyword)
|
||||
if len([]rune(input.Keyword)) < 1 || len([]rune(input.Keyword)) > 100 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "关键词长度应为 1–100 个字符"})
|
||||
return
|
||||
}
|
||||
if input.Limit < 1 || input.Limit > 20 {
|
||||
input.Limit = 10
|
||||
}
|
||||
songs, err := a.qq.Search(input.Keyword, input.Limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"songs": songs})
|
||||
}
|
||||
|
||||
func (a *localApp) lyrics(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
MID string `json:"mid"`
|
||||
}
|
||||
if err := decodeOneJSON(r, &input); err != nil || !songMIDPattern.MatchString(input.MID) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "歌曲标识无效"})
|
||||
return
|
||||
}
|
||||
result, err := a.qq.Lyrics(input.MID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func decodeOneJSON(r *http.Request, destination any) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
type fakeQQ struct{}
|
||||
|
||||
func (fakeQQ) Search(keyword string, limit int) ([]song, error) {
|
||||
return []song{{Name: keyword, MID: "0004jPDk2eB2dt", Singer: "Tester", Interval: limit}}, nil
|
||||
}
|
||||
func (fakeQQ) Lyrics(string) (lyricResult, error) {
|
||||
return lyricResult{Code: 0, Lyric: "[00:01.00]line"}, nil
|
||||
}
|
||||
|
||||
func testApp() (*localApp, http.Handler, fs.FS) {
|
||||
web := fstest.MapFS{
|
||||
"app.html": &fstest.MapFile{Data: []byte("app")},
|
||||
"app.js": &fstest.MapFile{Data: []byte("js")},
|
||||
"styles.css": &fstest.MapFile{Data: []byte("css")},
|
||||
}
|
||||
app := newApp("http://127.0.0.1:18765", "test-secret", web, fakeQQ{})
|
||||
return app, app.routes(), web
|
||||
}
|
||||
|
||||
func request(method, target, body string) *http.Request {
|
||||
r := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
r.Host = "127.0.0.1:18765"
|
||||
return r
|
||||
}
|
||||
|
||||
func validSessionCookie() *http.Cookie {
|
||||
return &http.Cookie{Name: sessionCookie, Value: "test-secret"}
|
||||
}
|
||||
|
||||
func TestLaunchTokenCreatesStrictSession(t *testing.T) {
|
||||
_, handler, _ := testApp()
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, request(http.MethodGet, "http://127.0.0.1:18765/start?t=test-secret", ""))
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("unsafe cookie: %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsWrongHost(t *testing.T) {
|
||||
_, handler, _ := testApp()
|
||||
r := request(http.MethodGet, "http://127.0.0.1:18765/health", "")
|
||||
r.Host = "attacker.example"
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsCrossOriginAPI(t *testing.T) {
|
||||
_, handler, _ := testApp()
|
||||
r := request(http.MethodPost, "http://127.0.0.1:18765/api/search", `{"keyword":"test","limit":10}`)
|
||||
r.AddCookie(validSessionCookie())
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Origin", "https://attacker.example")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSearchAndSecurityHeaders(t *testing.T) {
|
||||
_, handler, _ := testApp()
|
||||
r := request(http.MethodPost, "http://127.0.0.1:18765/api/search", `{"keyword":"测试","limit":10}`)
|
||||
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(), "测试") {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Security-Policy"), "default-src 'none'") {
|
||||
t.Fatal("strict CSP is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsInvalidMID(t *testing.T) {
|
||||
_, handler, _ := testApp()
|
||||
r := request(http.MethodPost, "http://127.0.0.1:18765/api/lyrics", `{"mid":"https://attacker.example/"}`)
|
||||
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.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>LRC Local</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div class="brand">LRC <span>Local</span></div>
|
||||
<div class="local-pill"><i></i> 本机运行中</div>
|
||||
</header>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">LOCAL-FIRST LYRIC TOOL</p>
|
||||
<h1>把歌词带走,<br><em>别把隐私留下。</em></h1>
|
||||
<p class="lead">搜索与歌词请求从这台电脑直接发出。关键词、歌词和下载文件不会经过我们的服务器。</p>
|
||||
<form id="search-form">
|
||||
<label for="keyword">歌曲、歌手或专辑</label>
|
||||
<div class="search-row">
|
||||
<input id="keyword" name="keyword" maxlength="100" autocomplete="off" placeholder="例如:起风了" required autofocus>
|
||||
<button type="submit">搜索歌词</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section id="results" class="results" aria-label="搜索结果"></section>
|
||||
<footer>非 QQ 音乐官方工具。请仅下载和使用你有权访问的歌词内容。</footer>
|
||||
</main>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
const form = document.querySelector('#search-form');
|
||||
const keyword = document.querySelector('#keyword');
|
||||
const notice = document.querySelector('#notice');
|
||||
const results = document.querySelector('#results');
|
||||
|
||||
function text(value) { return document.createTextNode(value ?? ''); }
|
||||
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 plainText(lrc) { return lrc.split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
|
||||
|
||||
async function api(path, payload) {
|
||||
const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function download(name, body, type = 'text/plain;charset=utf-8') {
|
||||
const url = URL.createObjectURL(new Blob([body], {type}));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = name; anchor.hidden = true;
|
||||
document.body.append(anchor); anchor.click(); anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
}
|
||||
|
||||
async function fetchAndSave(song, kind, button) {
|
||||
button.disabled = true;
|
||||
notice.className = 'notice'; notice.textContent = `正在读取《${song.name}》…`;
|
||||
try {
|
||||
const data = await api('/api/lyrics', {mid: song.mid});
|
||||
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);
|
||||
}
|
||||
notice.textContent = '文件已在浏览器中生成,内容未上传。';
|
||||
} catch (error) {
|
||||
notice.className = 'notice error'; notice.textContent = error.message;
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
|
||||
function renderSongs(songs) {
|
||||
results.replaceChildren();
|
||||
songs.forEach((song, index) => {
|
||||
const article = document.createElement('article'); article.className = 'song';
|
||||
const info = document.createElement('div');
|
||||
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(' · ')));
|
||||
info.append(title, meta);
|
||||
const actions = document.createElement('div'); actions.className = 'actions';
|
||||
[['lrc','下载 LRC','primary'],['txt','纯文本'],['trans','翻译 LRC']].forEach(([kind,label,klass]) => {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = klass || ''; button.append(text(label));
|
||||
button.addEventListener('click', () => fetchAndSave(song, kind, button)); actions.append(button);
|
||||
});
|
||||
article.append(info, actions); results.append(article);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true;
|
||||
notice.className = 'notice'; notice.textContent = '正在从这台电脑搜索…'; results.replaceChildren();
|
||||
try {
|
||||
const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15});
|
||||
renderSongs(data.songs || []); notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。` : '没有找到相关歌曲。';
|
||||
} catch (error) { notice.className = 'notice error'; notice.textContent = error.message; }
|
||||
finally { submit.disabled = false; }
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,"Segoe UI","PingFang SC",sans-serif;color:#eaf0ee;background:#09100e;color-scheme:dark;--mint:#75f0b2;--muted:#93a39e;--line:#26332f}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 85% 5%,#17442f 0,transparent 31rem),linear-gradient(145deg,#09100e,#101815 60%,#080d0b)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.12;background-image:linear-gradient(#fff 1px,transparent 1px),linear-gradient(90deg,#fff 1px,transparent 1px);background-size:48px 48px}.shell{width:min(1020px,calc(100% - 32px));margin:auto;padding:30px 0 48px;position:relative}header{display:flex;justify-content:space-between;align-items:center}.brand{font-size:1.1rem;font-weight:850;letter-spacing:.08em}.brand span{color:var(--mint)}.local-pill{border:1px solid #315044;background:#10251d;padding:8px 12px;border-radius:99px;color:#b9d3c9;font-size:.82rem}.local-pill i{display:inline-block;width:7px;height:7px;background:var(--mint);border-radius:50%;box-shadow:0 0 12px var(--mint);margin-right:6px}.hero{padding:90px 0 42px;max-width:780px}.eyebrow{color:var(--mint);font-family:ui-monospace,monospace;font-size:.78rem;letter-spacing:.18em}.hero h1{font-size:clamp(3.1rem,9vw,6.8rem);line-height:.91;letter-spacing:-.065em;margin:24px 0;font-weight:880}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px #9ec4b5}.lead{color:var(--muted);font-size:1.08rem;line-height:1.75;max-width:650px}form{margin-top:38px}label{display:block;font-size:.82rem;color:#b5c4bf;margin-bottom:9px}.search-row{display:flex;gap:10px}input{flex:1;min-width:0;background:#eff7f3;color:#0b1511;border:0;border-radius:8px;padding:17px 18px;font:inherit;font-size:1.04rem;outline:2px solid transparent}input:focus{outline-color:var(--mint)}button{border:0;border-radius:8px;padding:0 24px;background:var(--mint);color:#07100c;font:inherit;font-weight:800;cursor:pointer}button:hover{filter:brightness(1.06)}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;color:#b7cac2;margin-top:14px}.notice.error{color:#ff9b9b}.results{display:grid;gap:10px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px;align-items:center;padding:20px;border:1px solid var(--line);background:#101916d9;border-radius:12px}.song h2{font-size:1.05rem;margin:0 0 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:var(--muted);font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:7px}.actions button{background:#1c2a25;color:#d9e7e2;border:1px solid #31453e;padding:9px 12px;font-size:.82rem}.actions button.primary{background:var(--mint);border-color:var(--mint);color:#07100c}footer{margin-top:70px;padding-top:20px;border-top:1px solid var(--line);color:#75847f;font-size:.78rem}@media(max-width:700px){.shell{width:min(100% - 22px,1020px)}.hero{padding-top:65px}.hero h1{font-size:3.6rem}.search-row{display:grid}.search-row button{padding:15px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}.local-pill{font-size:.72rem}}
|
||||
Reference in New Issue
Block a user