Spicetify使用ivlyrics翻译歌词优化Spotify歌词体验

目录

不用 lyricsfy4 给 Spotify 完善歌词功能。

Spotify 的歌词一直很简陋,大多数歌曲是没有翻译的,而冷门的歌曲更是连歌词都没有。有一个很好的解决办法就是使用 lyricsfy 4 。这个也是维护了很久,很符合国人的使用习惯。

但使用 lyricsfy 4 是用 Electron 写的,内存占用太多了,没必要为了看翻译歌词单独开一个 Electron 应用。问了下 AI 有没有什么其他好更优雅的方案,发现还是有的。

一般是使用 spicetify 配合其他的插件使用。下面是配合 ivlyrics 完善歌词体验。

Spicetify

Spicetify 是 Spotify 的桌面客户端第三方插件加载工具。 功能是:

  • 修改 Spotify 的网页界面文件 xpui
  • 加载主题、Extensions 和 CustomApps
  • 为 ivLyrics 这类项目生成路由文件
1
scoop install spicetify-cli

官方文档写了对于非标准路径安装的 Spotify 需要设置:

1
2
$ scoop prefix spotify
C:\Users\<username>\scoop\apps\spotify\current
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 查看配置
spicetify config

# 添加 Custom App
spicetify config custom_apps ivLyrics

# 移除 Custom App 配置
spicetify config custom_apps ivLyrics-

# 将配置和插件应用到 Spotify
spicetify apply

# 撤销全部 Spicetify 修改,恢复原版 Spotify
spicetify restore

# 更新 Spicetify 自身
spicetify update

一般安装了插件之后,spicetify apply 重启 Spotify 就可以让插件生效。

插件 ivLyrics 安装

下载 ivLis-Studio/ivLyrics。 使用脚本安装,安装后 spicetify apply 重启 Spotify。出现 ivlyrics 的选项但是点进去提示出错了。 打开出错

排查是脚本将 ivLyrics 放进了 LocalAppData。正确做法是放入 roaming 里面。 local 一般是放只属于这台电脑的数据,roaming 则是放跟着用户走的数据。

对比项%APPDATA% (Roaming)%LOCALAPPDATA% (Local)
是否漫游会漫游不漫游
适用场景需要跟随用户账号同步的配置仅限本机的数据
数据特点体积较小、重要配置文件体积较大、缓存、临时文件、机器相关数据
域环境行为漫游用户配置文件时会同步到服务器不会同步,留在本地
典型用途软件设置、快捷方式配置、用户偏好浏览器缓存、游戏存档缓存、大型临时文件、日志

一般来说,spicetify 是安装在 local 目录下面,但是我是用 scoop 安装的有点不太一样。

local和roaming

可以通过 spicetify config-dir 命令查看 spicetify 的配置目录。

将 localappdata 里面的配置移动到 roaming 就能使用了。

1
2
3
4
5
6
7
8
9
$src = "$env:LOCALAPPDATA\spicetify\CustomApps\ivLyrics"
$dst = "$env:APPDATA\spicetify\CustomApps\ivLyrics"

Get-Process Spotify -ErrorAction SilentlyContinue | Stop-Process -Force

Remove-Item -LiteralPath $dst -Recurse -Force
New-Item -ItemType Junction -Path $dst -Target $src | Out-Null

spicetify apply

提了个 PR 把 Windows 硬编码的问题搞好了。有点奇怪的是作者测试的时候将安装脚本放到仓库里面,测试完成又删掉,给作者提建议之后就把安装脚本放到仓库里面了。这种行为未免太过不安全太过可疑,所以还是建议手动下载放到对应目录。


能打开 ivlyrics 后配置一些东西,然后加上 AI API 就能实现翻译歌词了。

用上之前薅的 grok super。 ivlyrics设置页

网易云插件

Ivlyrics 自带一个插件市场,发现居然还有网易云歌词插件。 ivlyrics插件市场

插件设置

需要配置一个 CORS 代理。考虑到地区的原因,尽量用国内的 IP。

CORS 是浏览器的“跨网站访问许可机制”。CORS 代理的作用是让代理代替浏览器访问网易云接口,再把结果安全地转交给插件。

浏览器有一个叫“同源策略”的安全限制。比如插件运行在 https://spotify.example ,直接请求 https://music.163.com/api/song/lyric 。 两个地址的域名不同,浏览器会检查网易云响应中有没有允许跨域访问的响应头。如果没有,即使网易云已经返回数据,浏览器也不会把数据交给插件。

所以需要对网易云返回的数据做一些处理,到达浏览器之前,让其看起来是允许跨域访问。

要么是用国内的 VPS 的 web 服务器搓一个,不过这么简单的需求,其实可以用 worker 来实现。 由于我的备案域名在阿里云我就用了阿里的边缘函数。

让 AI 搓一个 CORS PROXY:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
/**
 * NetEase Music CORS proxy for Alibaba Cloud ESA Edge Functions.
 *
 * Add-on configuration:
 *   https://YOUR_DOMAIN/?url={url}
 *
 * Security model:
 * - HTTPS only
 * - music.163.com only
 * - /api/* only
 * - GET, HEAD and OPTIONS only
 * - Never forwards the caller's cookies or authorization headers
 */

const ALLOWED_HOSTS = new Set(["music.163.com"]);
const ALLOWED_PATH_PREFIXES = ["/api/"];
const MAX_TARGET_URL_LENGTH = 4096;

function corsHeaders() {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
    "Access-Control-Allow-Headers":
      "Accept, Accept-Language, Content-Type, Origin, X-Requested-With",
    "Access-Control-Expose-Headers":
      "Content-Type, Content-Length, Date, ETag, Last-Modified",
    "Access-Control-Max-Age": "86400",
    "X-Content-Type-Options": "nosniff",
  };
}

function jsonResponse(data, status = 200) {
  const headers = new Headers(corsHeaders());
  headers.set("Content-Type", "application/json; charset=utf-8");
  headers.set("Cache-Control", "no-store");

  return new Response(JSON.stringify(data), {
    status,
    headers,
  });
}

function parseTarget(requestUrl) {
  const proxyUrl = new URL(requestUrl);
  let rawTarget = proxyUrl.searchParams.get("url");

  if (!rawTarget) {
    return { error: "Missing query parameter: url", status: 400 };
  }

  rawTarget = rawTarget.trim();

  // URLSearchParams normally decodes the value once. This extra pass also
  // accepts clients that encode the target URL twice.
  if (!/^https?:\/\//i.test(rawTarget)) {
    try {
      rawTarget = decodeURIComponent(rawTarget);
    } catch (_) {
      return { error: "Invalid URL encoding", status: 400 };
    }
  }

  if (rawTarget.length > MAX_TARGET_URL_LENGTH) {
    return { error: "Target URL is too long", status: 414 };
  }

  let target;
  try {
    target = new URL(rawTarget);
  } catch (_) {
    return { error: "Invalid target URL", status: 400 };
  }

  if (target.protocol !== "https:") {
    return { error: "Only HTTPS targets are allowed", status: 403 };
  }

  if (
    target.username ||
    target.password ||
    (target.port && target.port !== "443")
  ) {
    return { error: "Credentials and non-standard ports are not allowed", status: 403 };
  }

  const hostname = target.hostname.toLowerCase();
  if (!ALLOWED_HOSTS.has(hostname)) {
    return { error: "Target host is not allowed", status: 403 };
  }

  const pathAllowed = ALLOWED_PATH_PREFIXES.some((prefix) =>
    target.pathname.startsWith(prefix)
  );
  if (!pathAllowed) {
    return { error: "Target path is not allowed", status: 403 };
  }

  // Fragments are never sent in HTTP requests.
  target.hash = "";
  return { target };
}

function addCorsToUpstreamResponse(upstream) {
  const response = new Response(upstream.body, upstream);

  // Do not leak upstream cookies or preserve conflicting CORS policy.
  response.headers.delete("Set-Cookie");
  response.headers.delete("Set-Cookie2");
  response.headers.delete("Access-Control-Allow-Origin");
  response.headers.delete("Access-Control-Allow-Credentials");

  const cors = corsHeaders();
  for (const name of Object.keys(cors)) {
    response.headers.set(name, cors[name]);
  }

  response.headers.set("X-Cors-Proxy", "aliyun-esa");
  return response;
}

async function handleRequest(request) {
  const method = request.method.toUpperCase();

  if (method === "OPTIONS") {
    return new Response(null, {
      status: 204,
      headers: corsHeaders(),
    });
  }

  if (method !== "GET" && method !== "HEAD") {
    return jsonResponse(
      { ok: false, error: "Method not allowed" },
      405
    );
  }

  const requestUrl = new URL(request.url);
  if (requestUrl.pathname === "/health") {
    return jsonResponse({
      ok: true,
      service: "netease-cors-proxy",
    });
  }

  const parsed = parseTarget(request.url);
  if (parsed.error) {
    // Make the root URL convenient to open while setting up the function.
    if (
      parsed.status === 400 &&
      requestUrl.pathname === "/" &&
      !requestUrl.searchParams.has("url")
    ) {
      return jsonResponse({
        ok: true,
        service: "netease-cors-proxy",
        usage: "https://YOUR_DOMAIN/?url={url}",
      });
    }

    return jsonResponse(
      { ok: false, error: parsed.error },
      parsed.status
    );
  }

  const upstreamHeaders = new Headers();
  upstreamHeaders.set("Accept", "application/json, text/plain, */*");
  upstreamHeaders.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
  upstreamHeaders.set("Origin", "https://music.163.com");
  upstreamHeaders.set("Referer", "https://music.163.com/");
  upstreamHeaders.set(
    "User-Agent",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
      "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
  );
  upstreamHeaders.set("Cookie", "os=pc");

  try {
    const upstream = await fetch(parsed.target.toString(), {
      method,
      headers: upstreamHeaders,
      redirect: "manual",
    });

    // Do not let an upstream redirect turn this into a proxy for another host.
    if (upstream.status >= 300 && upstream.status < 400) {
      return jsonResponse(
        { ok: false, error: "Upstream redirect was blocked" },
        502
      );
    }

    return addCorsToUpstreamResponse(upstream);
  } catch (error) {
    return jsonResponse(
      {
        ok: false,
        error: "Upstream request failed",
        detail: String(error && error.message ? error.message : error),
      },
      502
    );
  }
}

export default {
  async fetch(request) {
    return handleRequest(request);
  },
};

页面展示

歌词效果还是不错的:

歌词页

歌词悬浮窗:

歌词悬浮窗

黑胶页面:

黑胶页面