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

不用 lyricsfy4 给 Spotify 完善歌词功能。
<!--more-->
Spotify 的歌词一直很简陋，大多数歌曲是没有翻译的，而冷门的歌曲更是连歌词都没有。有一个很好的解决办法就是使用 **lyricsfy 4** 。这个也是维护了很久，很符合国人的使用习惯。

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

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

## Spicetify
[Spicetify](https://spicetify.app/docs/getting-started) 是 Spotify 的桌面客户端第三方插件加载工具。
功能是：
- 修改 Spotify 的网页界面文件 `xpui`
- 加载主题、Extensions 和 CustomApps
- 为 ivLyrics 这类项目生成路由文件

```
scoop install spicetify-cli
```

官方文档写了对于非标准路径安装的 Spotify 需要设置：
```
$ scoop prefix spotify
C:\Users\<username>\scoop\apps\spotify\current
```

```
# 查看配置
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](https://github.com/ivLis-Studio/ivLyrics/releases)。
使用脚本安装，安装后 `spicetify apply` 重启 Spotify。出现 **ivlyrics** 的选项但是点进去提示出错了。
![打开出错](https://1mg.1144514.xyz/2026/07/Spicetify%20使用%20ivlyrics翻译歌词.webp)

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

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

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

![local和roaming](https://1mg.1144514.xyz/2026/07/Spicetify%20使用%20ivlyrics翻译歌词-1.webp)

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

将 `localappdata` 里面的配置移动到 `roaming` 就能使用了。
```pwsh
$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设置页](https://1mg.1144514.xyz/2026/07/Spicetify%20使用%20ivlyrics翻译歌词-2.webp)

## 网易云插件
Ivlyrics 自带一个插件市场，发现居然还有网易云歌词插件。
![ivlyrics插件市场](https://1mg.1144514.xyz/2026/07/20260724224435059.webp)

![插件设置](https://1mg.1144514.xyz/2026/07/20260724224450772.webp)

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

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

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

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

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

让 AI 搓一个 CORS PROXY：
```js
/**
 * 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);
  },
};

```

## 页面展示
歌词效果还是不错的：

![歌词页](https://1mg.1144514.xyz/2026/07/uTools_GIF_1784278758708.gif)

歌词悬浮窗：

![歌词悬浮窗](https://1mg.1144514.xyz/2026/07/uTools_GIF_1784279315862.gif)

黑胶页面：

![黑胶页面](https://1mg.1144514.xyz/2026/07/uTools_GIF_1784723543376.gif)


---

> 作者: Chifan  
> URL: https://eo.chifan.de/posts/ivlyrics/  

