mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846ee30bce | ||
|
|
051171b019 | ||
|
|
750ad9b1cc | ||
|
|
3841fa80d3 | ||
|
|
04ceac5a97 | ||
|
|
ada292574f | ||
|
|
15190413b2 | ||
|
|
f637ba0191 | ||
|
|
b9767a7471 | ||
|
|
47b29358ae | ||
|
|
cb66d77e9c | ||
|
|
9f5e27a277 | ||
|
|
486c3a0a69 | ||
|
|
8359c56dc7 | ||
|
|
a97e72ef30 | ||
|
|
a1cc0b8574 | ||
|
|
81b8953d52 |
No files matched your search
@@ -15,8 +15,6 @@
|
|||||||
"mcp__scheduled-tasks__*"
|
"mcp__scheduled-tasks__*"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push * main)",
|
|
||||||
"Bash(git push * master)",
|
|
||||||
"Bash(git push --force *)",
|
"Bash(git push --force *)",
|
||||||
"Bash(rm -rf /)"
|
"Bash(rm -rf /)"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ config.json
|
|||||||
cookies/
|
cookies/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
setup.log
|
||||||
/bin/
|
/bin/
|
||||||
scripts/navbar_bigger.png
|
scripts/navbar_bigger.png
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<h1 align="center">TSMusicBot</h1>
|
<h1 align="center">TSMusicBot</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
|
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -17,11 +17,15 @@
|
|||||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
||||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||||
|
<img src="https://img.shields.io/badge/Jellyfin-可选-aa5cc3?logo=jellyfin&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||||
|
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
> v1.10.0 新增**可选**的 [Jellyfin](https://jellyfin.org/) 音源(由 [@ItsEricRao](https://github.com/ItsEricRao) 在 [PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123) 中贡献):连接自建 Jellyfin 服务器直接播放你自己的音乐库。默认关闭,在 **设置 → Jellyfin 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||||
@@ -30,7 +34,8 @@
|
|||||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索,结果标注来源
|
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
|
||||||
|
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
|
||||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||||
@@ -162,6 +167,16 @@ sudo ./scripts/install.sh
|
|||||||
>
|
>
|
||||||
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
|
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
|
||||||
|
|
||||||
|
### 关于 enabledProviders 音源开关(v1.10.0 起)
|
||||||
|
|
||||||
|
v1.10.0([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123))引入 `enabledProviders` 音源开关与可选的 Jellyfin 音源。当前默认值为 `["netease", "qq", "bilibili", "youtube", "kugou"]`——**与旧版行为一致**,从更早版本升级**无需任何操作**,在线音源照常可用;Jellyfin 需要手动开启(详见 [可选:Jellyfin 音源](#可选jellyfin-音源))。
|
||||||
|
|
||||||
|
> ⚠️ **仅影响短暂运行过 v1.10.0 初版的用户**:该版本曾把默认音源设为 Jellyfin-only。如果你在那段时间保存过设置,`data/config.json` 中可能被写入了 `"enabledProviders": ["jellyfin"]`,升级后在线音源会保持停用。修复方法:把在线音源加回列表(或直接删除该字段以使用默认值),重启机器人(网易云 / QQ 的内嵌 API 服务需要重启才会启动):
|
||||||
|
>
|
||||||
|
> ```json
|
||||||
|
> "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
|
||||||
|
> ```
|
||||||
|
|
||||||
### 从 WebUI 无鉴权版本升级(重要)
|
### 从 WebUI 无鉴权版本升级(重要)
|
||||||
|
|
||||||
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
|
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
|
||||||
@@ -302,14 +317,15 @@ sudo systemctl start tsmusicbot
|
|||||||
- 机器人昵称
|
- 机器人昵称
|
||||||
- 可选:服务器密码、默认频道
|
- 可选:服务器密码、默认频道
|
||||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
|
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
|
||||||
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
|
||||||
|
5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
||||||
|
|
||||||
### WebUI 页面说明
|
### WebUI 页面说明
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换) |
|
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
|
||||||
| **搜索** | 四平台统一搜索,结果标注网易云/QQ/酷狗/B站来源,可一键收藏歌单 |
|
| **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
|
||||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
||||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||||
| **历史** | 播放历史记录 |
|
| **历史** | 播放历史记录 |
|
||||||
@@ -321,14 +337,16 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
|
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
|
||||||
|
| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
|
||||||
|
| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
|
||||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||||
| `!play -k <歌名>` | 从酷狗音乐搜索 |
|
| `!play -k <歌名>` | 从酷狗音乐搜索 |
|
||||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||||
| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
|
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
|
||||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||||
@@ -337,14 +355,15 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!queue` | 查看播放队列 |
|
| `!queue` | 查看播放队列 |
|
||||||
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
|
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
|
||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
| `!album <ID>` | 加载专辑 |
|
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
||||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
||||||
| `!fm` | 私人 FM(网易云,自动续播) |
|
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
||||||
|
| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
|
||||||
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||||
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
||||||
| `!lyrics` | 显示当前歌词 |
|
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
|
||||||
| `!now` | 当前播放信息 |
|
| `!now` | 当前播放信息 |
|
||||||
| `!vote` | 投票跳过当前歌曲 |
|
| `!vote` | 投票跳过当前歌曲 |
|
||||||
| `!move <频道名>` | 移动到指定频道 |
|
| `!move <频道名>` | 移动到指定频道 |
|
||||||
@@ -375,6 +394,8 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
### 音质等级
|
### 音质等级
|
||||||
|
|
||||||
|
**在线音源(网易云等)**
|
||||||
|
|
||||||
| 等级 | 码率 | 格式 | 说明 |
|
| 等级 | 码率 | 格式 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 标准 | 128kbps | MP3 | 免费可用 |
|
| 标准 | 128kbps | MP3 | 免费可用 |
|
||||||
@@ -384,6 +405,13 @@ sudo systemctl start tsmusicbot
|
|||||||
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
|
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
|
||||||
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
|
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
|
||||||
|
|
||||||
|
**Jellyfin(启用后)**
|
||||||
|
|
||||||
|
| 等级 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
|
||||||
|
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
|
||||||
|
|
||||||
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
|
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
|
||||||
|
|
||||||
## 项目架构
|
## 项目架构
|
||||||
@@ -407,6 +435,7 @@ teamspeak-music-bot/
|
|||||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
|
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
|
||||||
│ ├── music/ # 音源服务
|
│ ├── music/ # 音源服务
|
||||||
│ │ ├── provider.ts # 统一 MusicProvider 接口
|
│ │ ├── provider.ts # 统一 MusicProvider 接口
|
||||||
|
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
|
||||||
│ │ ├── netease.ts # 网易云音乐适配器
|
│ │ ├── netease.ts # 网易云音乐适配器
|
||||||
│ │ ├── qq.ts # QQ 音乐适配器
|
│ │ ├── qq.ts # QQ 音乐适配器
|
||||||
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
||||||
@@ -460,6 +489,7 @@ teamspeak-music-bot/
|
|||||||
| **数据库** | better-sqlite3 (SQLite) |
|
| **数据库** | better-sqlite3 (SQLite) |
|
||||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
||||||
|
| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
|
||||||
| **网易云 API** | NeteaseCloudMusicApi |
|
| **网易云 API** | NeteaseCloudMusicApi |
|
||||||
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
|
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
|
||||||
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
||||||
@@ -469,6 +499,63 @@ teamspeak-music-bot/
|
|||||||
| **图标** | @iconify/vue |
|
| **图标** | @iconify/vue |
|
||||||
| **日志** | pino |
|
| **日志** | pino |
|
||||||
|
|
||||||
|
## 可选:Jellyfin 音源
|
||||||
|
|
||||||
|
本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
|
||||||
|
|
||||||
|
### 启用与连接配置
|
||||||
|
|
||||||
|
三种方式任选:
|
||||||
|
|
||||||
|
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
|
||||||
|
2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
|
||||||
|
3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
|
||||||
|
|
||||||
|
两种认证方式:
|
||||||
|
|
||||||
|
| 模式 | 填写内容 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| **账号密码**(默认) | `username` + `password` | 以该用户身份登录(`AuthenticateByName`),token 自动持久化、失效自动重登 |
|
||||||
|
| **API Key** | `apiKey` + `userId` | 使用管理后台生成的 API Key;`userId` 决定使用谁的音乐库 / 收藏 / 歌单 |
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// config.json 片段
|
||||||
|
{
|
||||||
|
"jellyfin": {
|
||||||
|
"serverUrl": "https://jellyfin.example.com",
|
||||||
|
"authMode": "userpass", // 或 "apikey"
|
||||||
|
"username": "music",
|
||||||
|
"password": "······",
|
||||||
|
"apiKey": "", // apikey 模式填写
|
||||||
|
"userId": "" // apikey 模式填写
|
||||||
|
},
|
||||||
|
"enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
|
||||||
|
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
|
||||||
|
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
|
||||||
|
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
|
||||||
|
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
|
||||||
|
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
|
||||||
|
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
||||||
|
|
||||||
|
### enabledProviders:音源开关
|
||||||
|
|
||||||
|
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
|
||||||
|
|
||||||
|
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
|
||||||
|
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
|
||||||
|
- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
|
||||||
|
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
|
||||||
|
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
|
||||||
|
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
|
||||||
|
|
||||||
## 可选:YouTube 音源
|
## 可选:YouTube 音源
|
||||||
|
|
||||||
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
|
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
|
||||||
@@ -701,10 +788,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
|
|||||||
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
|
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
|
||||||
|
|
||||||
**Q:端口 3200 被占用?**
|
**Q:端口 3200 被占用?**
|
||||||
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
|
A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
|
||||||
|
|
||||||
|
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
|
||||||
|
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
|
||||||
|
|
||||||
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
|
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
|
||||||
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
|
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
|
||||||
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
|
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
|
||||||
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
|
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
|
||||||
修好版本后重新 `npm install && npm run build` 并重启即可。
|
修好版本后重新 `npm install && npm run build` 并重启即可。
|
||||||
@@ -764,9 +854,19 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
|||||||
|
|
||||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||||
|
|
||||||
### 最新版本
|
### 最新版本 — Jellyfin 可选音源
|
||||||
|
|
||||||
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
**Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
|
||||||
|
|
||||||
|
- **Jellyfin 音源(可选,默认关闭)**:连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源——搜索(歌曲 / 专辑 / 歌单,支持翻页)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」区块、播放进度回报(PlayCount / 播放状态)。账号密码或 API Key 两种认证,在 设置 → Jellyfin 音乐库 打开「启用 Jellyfin 音源」即可,保存即时生效。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)。
|
||||||
|
- **enabledProviders 音源开关**:`config.json` 新增 `enabledProviders` 字段,默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`——在线音源保持默认启用,**从旧版本升级无行为变化**;列表外的音源在聊天命令 / REST / WebUI 中一律不可用,网易云 / QQ 停用时其内嵌 API 服务(端口 3001 / 3200)不再启动。
|
||||||
|
- **新增 `-j`(Jellyfin)与 `-n`(网易云)平台标志**;不带标志的 `!play` / `!search` / `!fm` 等走固定优先级中第一个已启用的音源(默认配置下即网易云,行为与旧版一致)。
|
||||||
|
- ⚠️ **v1.10.0 初版曾短暂把默认音源设为 Jellyfin-only,现已回退**。若你在该版本保存过设置导致 `config.json` 中为 `"enabledProviders": ["jellyfin"]`,请手动把在线音源加回(详见 [更新升级](#关于-enabledproviders-音源开关v1100-起))。
|
||||||
|
- **QQ 按 ID 播放空歌名修复**([PR #124](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/124),感谢 [@Slldyd2077](https://github.com/Slldyd2077)):按 ID 播放 QQ 歌曲时回填歌曲元数据,TS 端不再显示空歌名。
|
||||||
|
|
||||||
|
### v1.9.0 及更早
|
||||||
|
|
||||||
|
**功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
|
||||||
|
|
||||||
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||||
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||||
@@ -774,9 +874,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
|||||||
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||||
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
||||||
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
|
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
|
||||||
|
- **#112 Spotify 音源(实验性)**:新增 Spotify 作为可选音源,默认关闭、需 Premium + 自建开发者应用(PKCE,无需 Client Secret)。采用混合 librespot 后端(Linux/Docker 用 go-librespot,Windows 用 Rust librespot),元数据走 Spotify Web API,可与现有音源混排入队。为 ToS 灰色地带的实验特性,详见 [Spotify 音源(实验性)](#spotify-音源实验性)。
|
||||||
|
- **#115 搜索结果翻页**:WebUI 搜索现按来源、按分类(歌曲 / 歌单 / 专辑)提供「加载更多」,服务端新增 `offset` 分页,不再固定只返回首页 20 首 / 10 个歌单 / 10 张专辑。
|
||||||
|
|
||||||
**Bug 修复**
|
**Bug 修复**
|
||||||
|
|
||||||
|
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
|
||||||
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
|
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
|
||||||
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
|
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
|
||||||
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
|
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
|
||||||
@@ -872,6 +975,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
|
|||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
|
||||||
|
| [ItsEricRao](https://github.com/ItsEricRao) | Jellyfin 音源集成贡献者([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123)) |
|
||||||
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
|
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
|
||||||
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
|
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
|
||||||
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
|
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Jellyfin 音源(主音源化)实施计划
|
||||||
|
|
||||||
|
目标:自建 Jellyfin 服务器成为默认且主要的音源;现有 NetEase / QQ / Bilibili / YouTube / Kugou
|
||||||
|
继续编译但默认停用(`enabledProviders` 配置门控,本次不删除)。约束:`src/audio/*`、
|
||||||
|
`src/ts-protocol/*` 不改(唯一例外:`queue.ts` 中 `QueuedSong.platform` 联合类型加一个成员,
|
||||||
|
纯类型加宽,无任何逻辑改动,否则无法编译)。
|
||||||
|
|
||||||
|
## 触点地图
|
||||||
|
|
||||||
|
后端:
|
||||||
|
- `src/music/provider.ts` — platform 联合类型加 `"jellyfin"`(Song/Playlist/Album/MusicProvider)
|
||||||
|
- `src/audio/queue.ts` — 同上(仅类型,一个词)
|
||||||
|
- `src/data/database.ts` — PlayHistoryRecord.platform 加 `"jellyfin"`
|
||||||
|
- `src/music/auth.ts` — CookieStore 平台加 `"jellyfin"`(token JSON 与 cookie 同路径持久化)
|
||||||
|
- `src/data/config.ts` — 新增 `JellyfinConfig`(serverUrl / authMode / username / password /
|
||||||
|
apiKey / userId)+ `enabledProviders`(默认 `["jellyfin"]`)+ 载入清洗 + `isProviderEnabled` /
|
||||||
|
`defaultPlatform` 助手。spotify 仍由 `spotify.enabled` 门控、local 仍由 `localAudioEnabled` 门控
|
||||||
|
- `src/music/jellyfin.ts` — 新 Provider:认证(userpass=AuthenticateByName + MediaBrowser 头,
|
||||||
|
401 重登一次;apikey=X-Emby-Token)、搜索(Audio/MusicAlbum/Playlist, StartIndex 翻页)、
|
||||||
|
懒解析播放 URL(direct=`/Audio/{id}/stream?static=true`;320/192/128=`/Audio/{id}/universal`
|
||||||
|
转码)、歌单/专辑/歌词(ticks→秒,404=无歌词)、收藏→InstantMix 电台链(收藏→最近播放→随机曲目)、
|
||||||
|
最近添加/最多播放/流派、播放上报(Sessions/Playing[/Progress|/Stopped],全部吞错)、
|
||||||
|
音质档(direct 原始直传 / 320k / 192k / 128k)
|
||||||
|
- `src/music/api-server.ts` — 按 provider 开关决定是否绑定 3001/3200
|
||||||
|
- `src/index.ts` — 构造 JellyfinProvider、加载持久化 token、穿线
|
||||||
|
- `src/bot/manager.ts` — 穿线 jellyfinProvider(与 spotifyProvider 同模式)
|
||||||
|
- `src/bot/instance.ts` — jellyfin 分支(getProviderFor / getProvider,新 flag `-j`/`-n`)、
|
||||||
|
默认平台=jellyfin、停用音源友好报错、歌单/专辑命令识别 GUID、播放上报挂钩、帮助文本
|
||||||
|
- `src/web/api/music.ts` — provider 选择 + 门控、`/providers`、jellyfin 首页数据端点、音质路由
|
||||||
|
- `src/web/api/auth.ts` — jellyfin 状态 + 测试连接(/System/Info);无 QR
|
||||||
|
- `src/web/api/player.ts` — 平台白名单 + platformFlag(netease 显式 `-n`)
|
||||||
|
- `src/web/api/bot.ts` — settings 读写 jellyfin 配置块(密码/APIKey 写入不回显)+ 热更新 provider
|
||||||
|
|
||||||
|
前端(Vue 3,保持 YesPlayMusic 风格;新增文案 zh+en):
|
||||||
|
- `stores/player.ts` — Source 加 jellyfin、enabledProviders 状态、jellyfin 首页数据
|
||||||
|
- `views/Search.vue` — 音源条只显示启用的 provider、Jellyfin 徽标
|
||||||
|
- `views/Home.vue` — 最近添加 / 播放最多 / 我的歌单 / 收藏 / 流派 区块 + jellyfin FM 卡片
|
||||||
|
- `views/Settings.vue` — Jellyfin 连接卡(地址/认证模式/凭据/测试连接)、隐藏停用 provider 的
|
||||||
|
登录卡、jellyfin 音质档
|
||||||
|
- `views/Setup.vue` — 向导第 3 步换成 Jellyfin 连接卡
|
||||||
|
- `views/Playlist.vue` 等 — ID 均按 string 处理,审计通过(GUID 无数字假设)
|
||||||
|
|
||||||
|
文档:README 增加「Jellyfin 音源」章节 + fork 出处与 MIT 归属(上游
|
||||||
|
ZHANGTIANYAO1/teamspeak-music-bot)。
|
||||||
|
|
||||||
|
## 阶段与验证
|
||||||
|
|
||||||
|
1. 类型 + 配置 → `npx tsc --noEmit`
|
||||||
|
2. JellyfinProvider + 单测 → tsc + vitest
|
||||||
|
3. 门控 + bot 穿线 → tsc + vitest
|
||||||
|
4. REST → tsc + vitest
|
||||||
|
5. WebUI → `cd web && npm run build`
|
||||||
|
6. README + 全量构建/测试
|
||||||
@@ -47,5 +47,13 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"@discordjs/opus@0.10.0": true,
|
||||||
|
"better-sqlite3@12.11.1": true,
|
||||||
|
"cpu-features@0.0.10": true,
|
||||||
|
"esbuild@0.28.1": true,
|
||||||
|
"ffmpeg-static@5.3.0": true,
|
||||||
|
"ssh2@1.17.0": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -10,10 +10,11 @@ export interface QueuedSong {
|
|||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||||
url?: string; // resolved lazily at play time
|
url?: string; // resolved lazily at play time
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
|
requestedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlayQueue {
|
export class PlayQueue {
|
||||||
|
|||||||
@@ -911,3 +911,64 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
|
||||||
|
it("splits a long command response into multiple sends, each under the byte cap", async () => {
|
||||||
|
const ctx = makeGateCtx({ adminGroups: [] });
|
||||||
|
const longResponse = Array.from(
|
||||||
|
{ length: 200 },
|
||||||
|
(_, i) => `歌词 line number ${i} with some content`,
|
||||||
|
).join("\n");
|
||||||
|
ctx.executeCommand = vi.fn(async () => longResponse);
|
||||||
|
|
||||||
|
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
|
||||||
|
|
||||||
|
const calls = ctx.tsClient.sendTextMessage.mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThan(1);
|
||||||
|
for (const [chunk] of calls) {
|
||||||
|
expect(Buffer.byteLength(chunk as string, "utf8")).toBeLessThanOrEqual(900);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a short command response as a single message", async () => {
|
||||||
|
const ctx = makeGateCtx({ adminGroups: [] });
|
||||||
|
ctx.executeCommand = vi.fn(async () => "short reply");
|
||||||
|
|
||||||
|
await handleTextMessage.call(ctx, makeMsg("!lyrics"));
|
||||||
|
|
||||||
|
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith("short reply");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmdLyrics = (BotInstance.prototype as any).cmdLyrics as (
|
||||||
|
this: unknown,
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
describe("BotInstance.cmdLyrics — full lyrics (#116)", () => {
|
||||||
|
it("returns ALL lyric lines, not just the first 10", async () => {
|
||||||
|
const lyricLines = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
time: i,
|
||||||
|
text: `lyric line ${i}`,
|
||||||
|
}));
|
||||||
|
const ctx: any = {
|
||||||
|
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
|
||||||
|
getProviderFor: () => ({ getLyrics: vi.fn(async () => lyricLines) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = await cmdLyrics.call(ctx);
|
||||||
|
|
||||||
|
for (const l of lyricLines) {
|
||||||
|
expect(out).toContain(l.text);
|
||||||
|
}
|
||||||
|
expect(out.startsWith("Lyrics for Song:")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'No lyrics available' when the provider has none", async () => {
|
||||||
|
const ctx: any = {
|
||||||
|
queue: { current: () => ({ id: "s1", name: "Song", platform: "netease" }) },
|
||||||
|
getProviderFor: () => ({ getLyrics: vi.fn(async () => []) }),
|
||||||
|
};
|
||||||
|
expect(await cmdLyrics.call(ctx)).toBe("No lyrics available");
|
||||||
|
});
|
||||||
|
});
|
||||||
+174
-52
@@ -6,16 +6,23 @@ import {
|
|||||||
} from "../ts-protocol/client.js";
|
} from "../ts-protocol/client.js";
|
||||||
import { AudioPlayer } from "../audio/player.js";
|
import { AudioPlayer } from "../audio/player.js";
|
||||||
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
||||||
import type { MusicProvider, Song } from "../music/provider.js";
|
import type { MusicProvider, Platform, Song } from "../music/provider.js";
|
||||||
import {
|
import {
|
||||||
parseCommand,
|
parseCommand,
|
||||||
canRunCommand,
|
canRunCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
import {
|
||||||
|
isProviderEnabled,
|
||||||
|
defaultPlatform,
|
||||||
|
type BotConfig,
|
||||||
|
type SpotifyConfig,
|
||||||
|
} from "../data/config.js";
|
||||||
|
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +86,7 @@ export interface BotInstanceOptions {
|
|||||||
localProvider?: MusicProvider;
|
localProvider?: MusicProvider;
|
||||||
kugouProvider?: MusicProvider;
|
kugouProvider?: MusicProvider;
|
||||||
spotifyProvider?: MusicProvider;
|
spotifyProvider?: MusicProvider;
|
||||||
|
jellyfinProvider?: MusicProvider;
|
||||||
database: BotDatabase;
|
database: BotDatabase;
|
||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -131,6 +139,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private localProvider: MusicProvider;
|
private localProvider: MusicProvider;
|
||||||
private kugouProvider: MusicProvider;
|
private kugouProvider: MusicProvider;
|
||||||
private spotifyProvider: MusicProvider;
|
private spotifyProvider: MusicProvider;
|
||||||
|
private jellyfinProvider: MusicProvider;
|
||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@@ -148,11 +157,15 @@ export class BotInstance extends EventEmitter {
|
|||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
private fmProvider: MusicProvider | null = null;
|
private fmProvider: MusicProvider | null = null;
|
||||||
|
private fmRequesterName: string | undefined;
|
||||||
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||||
private lastSearchResults: Song[] = [];
|
private lastSearchResults: Song[] = [];
|
||||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||||
private effectiveDuration: number | undefined;
|
private effectiveDuration: number | undefined;
|
||||||
private playGate: Promise<unknown> = Promise.resolve();
|
private playGate: Promise<unknown> = Promise.resolve();
|
||||||
|
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||||
|
* null when the wired provider has no reporting capability. */
|
||||||
|
private jellyfinReporter: JellyfinPlaybackReporter | null = null;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -165,6 +178,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||||
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
||||||
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
|
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
|
||||||
|
this.jellyfinProvider = options.jellyfinProvider ?? options.neteaseProvider;
|
||||||
this.database = options.database;
|
this.database = options.database;
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
this.logger = options.logger.child({ botId: this.id });
|
this.logger = options.logger.child({ botId: this.id });
|
||||||
@@ -174,6 +188,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
this.queue = new PlayQueue();
|
this.queue = new PlayQueue();
|
||||||
|
|
||||||
|
// Structural typing (like localProvider.sweepUnreferenced): only the real
|
||||||
|
// JellyfinProvider exposes createPlaybackReporter, so the netease fallback
|
||||||
|
// provider simply leaves reporting off.
|
||||||
|
const jfReportable = this.jellyfinProvider as MusicProvider & {
|
||||||
|
createPlaybackReporter?: () => JellyfinPlaybackReporter;
|
||||||
|
};
|
||||||
|
this.jellyfinReporter = jfReportable.createPlaybackReporter?.() ?? null;
|
||||||
|
|
||||||
// One long-lived Spotify sidecar controller per bot. Construction is
|
// One long-lived Spotify sidecar controller per bot. Construction is
|
||||||
// cheap and side-effect-free — nothing spawns until ensureStarted().
|
// cheap and side-effect-free — nothing spawns until ensureStarted().
|
||||||
const spotifyBase =
|
const spotifyBase =
|
||||||
@@ -310,6 +332,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.spotifyController.stop();
|
this.spotifyController.stop();
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.jellyfinReporter?.onStop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("disconnected");
|
this.sweepLocalAudio("disconnected");
|
||||||
// A lifecycle change must not leave a stale auto-resume armed.
|
// A lifecycle change must not leave a stale auto-resume armed.
|
||||||
@@ -325,6 +348,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this._startIdlePoller();
|
this._startIdlePoller();
|
||||||
|
this._startJellyfinReportPoller();
|
||||||
});
|
});
|
||||||
|
|
||||||
// React near-instantly to channel membership changes. The 30s idle
|
// React near-instantly to channel membership changes. The 30s idle
|
||||||
@@ -395,6 +419,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.spotifyController.stop();
|
this.spotifyController.stop();
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.jellyfinReporter?.onStop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("disconnected");
|
this.sweepLocalAudio("disconnected");
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
@@ -473,6 +498,29 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ~10s Jellyfin progress reporting, following the _startIdlePoller pattern:
|
||||||
|
* a self-rescheduling timeout guarded by this.connected, so it needs no
|
||||||
|
* explicit teardown. When the current track is not (or no longer) a jellyfin
|
||||||
|
* item, onStop() idempotently closes any open report session.
|
||||||
|
*/
|
||||||
|
private _startJellyfinReportPoller(): void {
|
||||||
|
if (!this.jellyfinReporter) return;
|
||||||
|
const tick = () => {
|
||||||
|
if (!this.connected) return;
|
||||||
|
const reporter = this.jellyfinReporter!;
|
||||||
|
const current = this.queue.current();
|
||||||
|
const state = this.player.getState();
|
||||||
|
if (current?.platform === "jellyfin" && (state === "playing" || state === "paused")) {
|
||||||
|
reporter.onTick(current.id, this.player.getElapsed(), state === "paused");
|
||||||
|
} else {
|
||||||
|
reporter.onStop();
|
||||||
|
}
|
||||||
|
setTimeout(tick, 10_000);
|
||||||
|
};
|
||||||
|
setTimeout(tick, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
private _scheduleIdleCheck(): void {
|
private _scheduleIdleCheck(): void {
|
||||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||||
@@ -520,7 +568,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const response = await this.executeCommand(parsed, msg);
|
const response = await this.executeCommand(parsed, msg);
|
||||||
if (response) {
|
if (response) {
|
||||||
await this.tsClient.sendTextMessage(response);
|
// A single long reply (e.g. full lyrics) would exceed TeamSpeak's
|
||||||
|
// per-message byte cap, so split it and send the chunks in order.
|
||||||
|
for (const chunk of splitTextIntoChunks(response)) {
|
||||||
|
await this.tsClient.sendTextMessage(chunk);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||||
@@ -571,7 +623,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
async executeCommand(
|
async executeCommand(
|
||||||
cmd: ParsedCommand,
|
cmd: ParsedCommand,
|
||||||
msg?: TS3TextMessage
|
msg?: TS3TextMessage,
|
||||||
|
requesterName = this.requesterNameFromMessage(msg),
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Reject commands that would push audio when the bot isn't connected:
|
// Reject commands that would push audio when the bot isn't connected:
|
||||||
// otherwise ffmpeg spawns and voice goes to a half-initialized or
|
// otherwise ffmpeg spawns and voice goes to a half-initialized or
|
||||||
@@ -599,12 +652,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
case "find":
|
case "find":
|
||||||
return this.cmdSearch(cmd);
|
return this.cmdSearch(cmd);
|
||||||
case "play":
|
case "play":
|
||||||
return this.cmdPlay(cmd);
|
return this.cmdPlay(cmd, requesterName);
|
||||||
case "add":
|
case "add":
|
||||||
return this.cmdAdd(cmd);
|
return this.cmdAdd(cmd, requesterName);
|
||||||
case "playnext":
|
case "playnext":
|
||||||
case "pn":
|
case "pn":
|
||||||
return this.cmdPlayNext(cmd);
|
return this.cmdPlayNext(cmd, requesterName);
|
||||||
case "pause":
|
case "pause":
|
||||||
return this.cmdPause();
|
return this.cmdPause();
|
||||||
case "resume":
|
case "resume":
|
||||||
@@ -630,13 +683,13 @@ export class BotInstance extends EventEmitter {
|
|||||||
case "mode":
|
case "mode":
|
||||||
return this.cmdMode(cmd);
|
return this.cmdMode(cmd);
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return this.cmdPlaylist(cmd);
|
return this.cmdPlaylist(cmd, requesterName);
|
||||||
case "album":
|
case "album":
|
||||||
return this.cmdAlbum(cmd);
|
return this.cmdAlbum(cmd, requesterName);
|
||||||
case "fm":
|
case "fm":
|
||||||
return this.cmdFm(cmd);
|
return this.cmdFm(cmd, requesterName);
|
||||||
case "artist":
|
case "artist":
|
||||||
return this.cmdArtist(cmd);
|
return this.cmdArtist(cmd, requesterName);
|
||||||
case "vote":
|
case "vote":
|
||||||
return this.cmdVote(msg);
|
return this.cmdVote(msg);
|
||||||
case "lyrics":
|
case "lyrics":
|
||||||
@@ -652,27 +705,67 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider {
|
getProviderFor(platform: Platform): MusicProvider {
|
||||||
if (platform === "bilibili") return this.bilibiliProvider;
|
if (platform === "bilibili") return this.bilibiliProvider;
|
||||||
if (platform === "youtube") return this.youtubeProvider;
|
if (platform === "youtube") return this.youtubeProvider;
|
||||||
if (platform === "local") return this.localProvider;
|
if (platform === "local") return this.localProvider;
|
||||||
if (platform === "kugou") return this.kugouProvider;
|
if (platform === "kugou") return this.kugouProvider;
|
||||||
if (platform === "spotify") return this.spotifyProvider;
|
if (platform === "spotify") return this.spotifyProvider;
|
||||||
|
if (platform === "jellyfin") return this.jellyfinProvider;
|
||||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Friendly gate for user-selected platforms (flags / URLs / REST params). */
|
||||||
|
assertProviderEnabled(platform: Platform): void {
|
||||||
|
if (!isProviderEnabled(this.config, platform)) {
|
||||||
|
throw new Error(
|
||||||
|
`音源未启用:${platform}(provider disabled — 需在配置 enabledProviders 中开启)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private disableFmMode(): void {
|
private disableFmMode(): void {
|
||||||
this.isFmMode = false;
|
this.isFmMode = false;
|
||||||
this.fmProvider = null;
|
this.fmProvider = null;
|
||||||
|
this.fmRequesterName = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chat-command source flags. No flag → the configured default platform
|
||||||
|
* (netease in the default config; otherwise the first enabled source by
|
||||||
|
* fixed priority — see defaultPlatform()). */
|
||||||
|
private static readonly FLAG_PLATFORMS: ReadonlyArray<[string, Platform]> = [
|
||||||
|
["b", "bilibili"],
|
||||||
|
["q", "qq"],
|
||||||
|
["y", "youtube"],
|
||||||
|
["k", "kugou"],
|
||||||
|
["s", "spotify"],
|
||||||
|
["n", "netease"],
|
||||||
|
["j", "jellyfin"],
|
||||||
|
];
|
||||||
|
|
||||||
private getProvider(flags: Set<string>): MusicProvider {
|
private getProvider(flags: Set<string>): MusicProvider {
|
||||||
if (flags.has("b")) return this.bilibiliProvider;
|
for (const [flag, platform] of BotInstance.FLAG_PLATFORMS) {
|
||||||
if (flags.has("q")) return this.qqProvider;
|
if (flags.has(flag)) {
|
||||||
if (flags.has("y")) return this.youtubeProvider;
|
this.assertProviderEnabled(platform);
|
||||||
if (flags.has("k")) return this.kugouProvider;
|
return this.getProviderFor(platform);
|
||||||
if (flags.has("s")) return this.spotifyProvider;
|
}
|
||||||
return this.neteaseProvider;
|
}
|
||||||
|
const def = defaultPlatform(this.config);
|
||||||
|
this.assertProviderEnabled(def);
|
||||||
|
return this.getProviderFor(def);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
|
||||||
|
const name = msg?.invokerName?.trim();
|
||||||
|
return name || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withRequester<T extends Song | QueuedSong>(
|
||||||
|
song: T,
|
||||||
|
requesterName?: string,
|
||||||
|
): T & { requestedBy?: string } {
|
||||||
|
const requestedBy = requesterName?.trim();
|
||||||
|
return requestedBy ? { ...song, requestedBy } : { ...song };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
|
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
|
||||||
@@ -772,6 +865,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player.resume();
|
this.player.resume();
|
||||||
}
|
}
|
||||||
this.currentSourceIsSpotify = true;
|
this.currentSourceIsSpotify = true;
|
||||||
|
this.jellyfinReporter?.onStop();
|
||||||
song.url = result.url;
|
song.url = result.url;
|
||||||
// No trial clip for Spotify — full-track duration only (the near-end
|
// No trial clip for Spotify — full-track duration only (the near-end
|
||||||
// stall watchdog is disabled for the external stream anyway).
|
// stall watchdog is disabled for the external stream anyway).
|
||||||
@@ -785,6 +879,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
album: song.album,
|
album: song.album,
|
||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
coverUrl: song.coverUrl,
|
||||||
|
requestedBy: song.requestedBy,
|
||||||
});
|
});
|
||||||
await this.syncProfileToSong(song);
|
await this.syncProfileToSong(song);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
@@ -801,6 +896,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
||||||
this.effectiveDuration = result.trialDuration ?? song.duration;
|
this.effectiveDuration = result.trialDuration ?? song.duration;
|
||||||
this.player.play(result.url, 0, this.effectiveDuration);
|
this.player.play(result.url, 0, this.effectiveDuration);
|
||||||
|
// Jellyfin playback reporting: open a session for jellyfin tracks (the
|
||||||
|
// reporter closes the previous one itself); close any open session when
|
||||||
|
// playback moves to another source. Fire-and-forget — never blocks play.
|
||||||
|
if (song.platform === "jellyfin") this.jellyfinReporter?.onTrackStart(song.id);
|
||||||
|
else this.jellyfinReporter?.onStop();
|
||||||
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||||
// change won't try to "resume" a track the user already restarted.
|
// change won't try to "resume" a track the user already restarted.
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
@@ -812,6 +912,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
album: song.album,
|
album: song.album,
|
||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
coverUrl: song.coverUrl,
|
||||||
|
requestedBy: song.requestedBy,
|
||||||
});
|
});
|
||||||
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
||||||
await this.syncProfileToSong(song);
|
await this.syncProfileToSong(song);
|
||||||
@@ -855,6 +956,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
// 2) id:/URL — fetch that exact song.
|
// 2) id:/URL — fetch that exact song.
|
||||||
const ref = parseSongRef(args);
|
const ref = parseSongRef(args);
|
||||||
if (ref) {
|
if (ref) {
|
||||||
|
if (ref.platform) this.assertProviderEnabled(ref.platform);
|
||||||
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
|
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
|
||||||
const song = await provider.getSongDetail(ref.id);
|
const song = await provider.getSongDetail(ref.id);
|
||||||
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
|
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
|
||||||
@@ -884,7 +986,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
@@ -895,7 +997,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
this.queue.add({ ...song0 });
|
this.queue.add(this.withRequester(song0, requesterName));
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
// Reset failure counter on user-initiated play
|
// Reset failure counter on user-initiated play
|
||||||
@@ -909,14 +1011,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Now playing: ${song0.name} - ${song0.artist}`;
|
return `Now playing: ${song0.name} - ${song0.artist}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
const s = song!;
|
const s = song!;
|
||||||
|
|
||||||
const wasIdle = this.player.getState() === "idle";
|
const wasIdle = this.player.getState() === "idle";
|
||||||
this.queue.add({ ...s });
|
this.queue.add(this.withRequester(s, requesterName));
|
||||||
|
|
||||||
// If nothing was playing, start this newly-added song immediately.
|
// If nothing was playing, start this newly-added song immediately.
|
||||||
// Matches /api/player/:id/add-by-id behavior so both add paths feel
|
// Matches /api/player/:id/add-by-id behavior so both add paths feel
|
||||||
@@ -933,7 +1035,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
|
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
|
||||||
const { song, error } = await this.resolvePlayQuery(cmd);
|
const { song, error } = await this.resolvePlayQuery(cmd);
|
||||||
if (error) return error;
|
if (error) return error;
|
||||||
@@ -949,7 +1051,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.getCurrentIndex() < 0
|
this.queue.getCurrentIndex() < 0
|
||||||
? this.queue.size()
|
? this.queue.size()
|
||||||
: this.queue.getCurrentIndex() + 1;
|
: this.queue.getCurrentIndex() + 1;
|
||||||
this.queue.addNext({ ...s });
|
this.queue.addNext(this.withRequester(s, requesterName));
|
||||||
|
|
||||||
if (wasIdle) {
|
if (wasIdle) {
|
||||||
this.queue.playAt(insertedAt);
|
this.queue.playAt(insertedAt);
|
||||||
@@ -996,6 +1098,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.jellyfinReporter?.onStop();
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("stopped");
|
this.sweepLocalAudio("stopped");
|
||||||
@@ -1058,6 +1161,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.spotifyController.stop();
|
this.spotifyController.stop();
|
||||||
this.currentSourceIsSpotify = false;
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.jellyfinReporter?.onStop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("queue_cleared");
|
this.sweepLocalAudio("queue_cleared");
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
@@ -1114,18 +1218,18 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Play mode set to: ${cmd.args}`;
|
return `Play mode set to: ${cmd.args}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
|
||||||
// Determine if input is a numeric ID or a name search
|
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
||||||
const id = this.extractId(cmd.args);
|
const id = this.extractId(cmd.args);
|
||||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
const isDirectId = this.looksLikeCollectionId(cmd.args);
|
||||||
|
|
||||||
let playlistId: string;
|
let playlistId: string;
|
||||||
|
|
||||||
if (isNumericId || id !== cmd.args) {
|
if (isDirectId || id !== cmd.args) {
|
||||||
// Input is a numeric ID or URL containing an ID — use existing logic
|
// Input is a direct ID or URL containing an ID — use existing logic
|
||||||
playlistId = id;
|
playlistId = id;
|
||||||
} else {
|
} else {
|
||||||
// Name-based search
|
// Name-based search
|
||||||
@@ -1159,7 +1263,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
|
||||||
}
|
}
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
@@ -1168,17 +1272,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !album <album name or ID>";
|
if (!cmd.args) return "Usage: !album <album name or ID>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
|
||||||
const id = this.extractId(cmd.args);
|
const id = this.extractId(cmd.args);
|
||||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
const isDirectId = this.looksLikeCollectionId(cmd.args);
|
||||||
|
|
||||||
let albumId: string;
|
let albumId: string;
|
||||||
|
|
||||||
if (isNumericId || id !== cmd.args) {
|
if (isDirectId || id !== cmd.args) {
|
||||||
// Input is a numeric ID or URL containing an ID — use directly
|
// Input is a direct ID (numeric / Jellyfin GUID) or URL containing an ID — use directly
|
||||||
albumId = id;
|
albumId = id;
|
||||||
} else {
|
} else {
|
||||||
// Name-based search
|
// Name-based search
|
||||||
@@ -1196,7 +1300,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
|
||||||
}
|
}
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
@@ -1205,11 +1309,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdFm(cmd: ParsedCommand): Promise<string> {
|
private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
return this.startFm(this.getProvider(cmd.flags));
|
return this.startFm(this.getProvider(cmd.flags), requesterName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
|
async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise<string> {
|
||||||
// Match the !fm chat-command guard: refuse before mutating the queue when
|
// Match the !fm chat-command guard: refuse before mutating the queue when
|
||||||
// offline, so the web /fm route can't wipe the queue + flip into FM mode
|
// offline, so the web /fm route can't wipe the queue + flip into FM mode
|
||||||
// while nothing can actually play.
|
// while nothing can actually play.
|
||||||
@@ -1226,11 +1330,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
|
||||||
}
|
}
|
||||||
this.queue.setMode(PlayMode.Random);
|
this.queue.setMode(PlayMode.Random);
|
||||||
this.isFmMode = true;
|
this.isFmMode = true;
|
||||||
this.fmProvider = provider;
|
this.fmProvider = provider;
|
||||||
|
this.fmRequesterName = requesterName?.trim() || undefined;
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
|
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
@@ -1241,7 +1346,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !artist <artist name>";
|
if (!cmd.args) return "Usage: !artist <artist name>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
const result = await provider.search(cmd.args, 50);
|
const result = await provider.search(cmd.args, 50);
|
||||||
@@ -1262,7 +1367,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.disableFmMode();
|
this.disableFmMode();
|
||||||
for (const song of filtered) {
|
for (const song of filtered) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
|
||||||
}
|
}
|
||||||
this.queue.setMode(PlayMode.Loop);
|
this.queue.setMode(PlayMode.Loop);
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
@@ -1281,7 +1386,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
const songs = await provider.getPersonalFm();
|
const songs = await provider.getPersonalFm();
|
||||||
if (songs.length === 0) return;
|
if (songs.length === 0) return;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, this.fmRequesterName));
|
||||||
}
|
}
|
||||||
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
|
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1316,7 +1421,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
const provider = this.getProviderFor(song.platform);
|
const provider = this.getProviderFor(song.platform);
|
||||||
const lyrics = await provider.getLyrics(song.id);
|
const lyrics = await provider.getLyrics(song.id);
|
||||||
if (lyrics.length === 0) return "No lyrics available";
|
if (lyrics.length === 0) return "No lyrics available";
|
||||||
const lines = lyrics.slice(0, 10).map((l) => l.text);
|
// Include the FULL lyrics (the send path chunks them under the message
|
||||||
|
// cap). Cap only to avoid pathological spam — far above any normal song.
|
||||||
|
const MAX_LYRIC_LINES = 200;
|
||||||
|
const lines = lyrics.slice(0, MAX_LYRIC_LINES).map((l) => l.text);
|
||||||
return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
|
return `Lyrics for ${song.name}:\n${lines.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,15 +1441,20 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdHelp(): string {
|
private cmdHelp(): string {
|
||||||
const p = this.config.commandPrefix;
|
const p = this.config.commandPrefix;
|
||||||
|
const def = defaultPlatform(this.config);
|
||||||
|
// Only advertise source flags whose provider is actually enabled.
|
||||||
|
const flagHelp = BotInstance.FLAG_PLATFORMS.filter(([, platform]) =>
|
||||||
|
isProviderEnabled(this.config, platform),
|
||||||
|
)
|
||||||
|
.map(([flag, platform]) => `-${flag}=${platform}`)
|
||||||
|
.join(" ");
|
||||||
return [
|
return [
|
||||||
"TSMusicBot Commands:",
|
"TSMusicBot Commands:",
|
||||||
`${p}play <song> — Search and play (most popular match)`,
|
`${p}play <song> — Search and play (default source: ${def})`,
|
||||||
`${p}play -q <song> — Search from QQ Music`,
|
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
||||||
`${p}play -b <song> — Search from BiliBili`,
|
|
||||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
|
||||||
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
||||||
`${p}play #N — Play the Nth result of the last ${p}search`,
|
`${p}play #N — Play the Nth result of the last ${p}search`,
|
||||||
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
|
`${p}play id:<id> — Play an exact song by id / URL`,
|
||||||
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
||||||
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
||||||
`${p}pause/resume — Pause/resume`,
|
`${p}pause/resume — Pause/resume`,
|
||||||
@@ -1352,11 +1465,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
|
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
|
||||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
||||||
`${p}playlist <name or id> — Load playlist by name or ID`,
|
`${p}playlist <name or id> — Load playlist by name or ID`,
|
||||||
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
|
`${p}album <name or id> — Load album`,
|
||||||
`${p}album <id> — Load album`,
|
`${p}fm — Personal FM (default source: ${def}; source flags work too)`,
|
||||||
`${p}fm — Personal FM (NetEase)`,
|
|
||||||
`${p}artist <name> — Play songs by artist (loop)`,
|
`${p}artist <name> — Play songs by artist (loop)`,
|
||||||
`${p}artist -q <name> — Artist loop from QQ Music`,
|
|
||||||
`${p}vote — Vote to skip`,
|
`${p}vote — Vote to skip`,
|
||||||
`${p}lyrics — Show lyrics`,
|
`${p}lyrics — Show lyrics`,
|
||||||
`${p}now — Current song info`,
|
`${p}now — Current song info`,
|
||||||
@@ -1444,6 +1555,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Direct collection ids: numeric (NetEase/QQ) or Jellyfin GUID ItemIds
|
||||||
|
* (32 hex chars, optionally dashed) — never treat those as name searches. */
|
||||||
|
private looksLikeCollectionId(raw: string): boolean {
|
||||||
|
const t = raw.trim();
|
||||||
|
return (
|
||||||
|
/^\d+$/.test(t) ||
|
||||||
|
/^[0-9a-fA-F]{32}$/.test(t) ||
|
||||||
|
/^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Serialize queue-mutation + play sequences so concurrent requests can't
|
/** Serialize queue-mutation + play sequences so concurrent requests can't
|
||||||
* interleave (audible track must match queue.currentIndex). */
|
* interleave (audible track must match queue.currentIndex). */
|
||||||
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
|||||||
+7
-1
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
|
|||||||
private localProvider: MusicProvider;
|
private localProvider: MusicProvider;
|
||||||
private kugouProvider: MusicProvider;
|
private kugouProvider: MusicProvider;
|
||||||
private spotifyProvider: MusicProvider;
|
private spotifyProvider: MusicProvider;
|
||||||
|
private jellyfinProvider: MusicProvider;
|
||||||
private spotifyDataDir: string;
|
private spotifyDataDir: string;
|
||||||
private readonly spotifyOAuth?: SpotifyOAuth;
|
private readonly spotifyOAuth?: SpotifyOAuth;
|
||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
@@ -102,7 +103,8 @@ export class BotManager extends EventEmitter {
|
|||||||
kugouProvider?: MusicProvider,
|
kugouProvider?: MusicProvider,
|
||||||
spotifyProvider?: MusicProvider,
|
spotifyProvider?: MusicProvider,
|
||||||
spotifyDataDir?: string,
|
spotifyDataDir?: string,
|
||||||
spotifyOAuth?: SpotifyOAuth
|
spotifyOAuth?: SpotifyOAuth,
|
||||||
|
jellyfinProvider?: MusicProvider
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.neteaseProvider = neteaseProvider;
|
this.neteaseProvider = neteaseProvider;
|
||||||
@@ -112,6 +114,7 @@ export class BotManager extends EventEmitter {
|
|||||||
this.localProvider = localProvider ?? neteaseProvider;
|
this.localProvider = localProvider ?? neteaseProvider;
|
||||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||||
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
||||||
|
this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
|
||||||
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||||
this.spotifyOAuth = spotifyOAuth;
|
this.spotifyOAuth = spotifyOAuth;
|
||||||
// Let the local provider see which uploads are still referenced by any
|
// Let the local provider see which uploads are still referenced by any
|
||||||
@@ -153,6 +156,7 @@ export class BotManager extends EventEmitter {
|
|||||||
localProvider: this.localProvider,
|
localProvider: this.localProvider,
|
||||||
kugouProvider: this.kugouProvider,
|
kugouProvider: this.kugouProvider,
|
||||||
spotifyProvider: this.spotifyProvider,
|
spotifyProvider: this.spotifyProvider,
|
||||||
|
jellyfinProvider: this.jellyfinProvider,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
@@ -296,6 +300,7 @@ export class BotManager extends EventEmitter {
|
|||||||
localProvider: this.localProvider,
|
localProvider: this.localProvider,
|
||||||
kugouProvider: this.kugouProvider,
|
kugouProvider: this.kugouProvider,
|
||||||
spotifyProvider: this.spotifyProvider,
|
spotifyProvider: this.spotifyProvider,
|
||||||
|
jellyfinProvider: this.jellyfinProvider,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
@@ -353,6 +358,7 @@ export class BotManager extends EventEmitter {
|
|||||||
localProvider: this.localProvider,
|
localProvider: this.localProvider,
|
||||||
kugouProvider: this.kugouProvider,
|
kugouProvider: this.kugouProvider,
|
||||||
spotifyProvider: this.spotifyProvider,
|
spotifyProvider: this.spotifyProvider,
|
||||||
|
jellyfinProvider: this.jellyfinProvider,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||||
|
|
||||||
|
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
|
||||||
|
|
||||||
|
describe("splitTextIntoChunks", () => {
|
||||||
|
it("returns a single chunk for a short string", () => {
|
||||||
|
const chunks = splitTextIntoChunks("hello world", 900);
|
||||||
|
expect(chunks).toEqual(["hello world"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
|
||||||
|
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
|
||||||
|
const text = lines.join("\n");
|
||||||
|
const chunks = splitTextIntoChunks(text, 60);
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const c of chunks) {
|
||||||
|
expect(bytes(c)).toBeLessThanOrEqual(60);
|
||||||
|
}
|
||||||
|
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
|
||||||
|
expect(chunks.join("\n")).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
|
||||||
|
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
|
||||||
|
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
|
||||||
|
const text = lines.join("\n");
|
||||||
|
const chunks = splitTextIntoChunks(text, 150);
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const c of chunks) {
|
||||||
|
expect(bytes(c)).toBeLessThanOrEqual(150);
|
||||||
|
}
|
||||||
|
expect(chunks.join("\n")).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
|
||||||
|
const longLine = "a".repeat(500);
|
||||||
|
const chunks = splitTextIntoChunks(longLine, 100);
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const c of chunks) {
|
||||||
|
expect(bytes(c)).toBeLessThanOrEqual(100);
|
||||||
|
}
|
||||||
|
// Content is preserved (hard-split introduces split points, not \n).
|
||||||
|
expect(chunks.join("")).toBe(longLine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never splits a multibyte character across a hard-split boundary", () => {
|
||||||
|
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
|
||||||
|
const longLine = "歌".repeat(200);
|
||||||
|
const chunks = splitTextIntoChunks(longLine, 40);
|
||||||
|
|
||||||
|
for (const c of chunks) {
|
||||||
|
expect(bytes(c)).toBeLessThanOrEqual(40);
|
||||||
|
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
|
||||||
|
expect(c.includes("�")).toBe(false);
|
||||||
|
}
|
||||||
|
expect(chunks.join("")).toBe(longLine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blank lines within a single chunk", () => {
|
||||||
|
const text = "a\n\nb";
|
||||||
|
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
|
||||||
|
*
|
||||||
|
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
|
||||||
|
* does no chunking, so a long single reply (e.g. full song lyrics) would be
|
||||||
|
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
|
||||||
|
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
|
||||||
|
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
|
||||||
|
* multibyte character is ever cut in half.
|
||||||
|
*
|
||||||
|
* Content is preserved on rejoin, modulo the split points: chunks split only on
|
||||||
|
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
|
||||||
|
* long line rejoins with `chunks.join("")`.
|
||||||
|
*
|
||||||
|
* @param text The full message text.
|
||||||
|
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
|
||||||
|
* with headroom for protocol framing/escaping).
|
||||||
|
*/
|
||||||
|
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
if (current !== "") {
|
||||||
|
chunks.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of text.split("\n")) {
|
||||||
|
const pieces =
|
||||||
|
Buffer.byteLength(rawLine, "utf8") > maxBytes
|
||||||
|
? hardSplitByBytes(rawLine, maxBytes)
|
||||||
|
: [rawLine];
|
||||||
|
|
||||||
|
for (const piece of pieces) {
|
||||||
|
const candidate = current === "" ? piece : `${current}\n${piece}`;
|
||||||
|
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
// current is guaranteed non-empty here: pieces never exceed maxBytes,
|
||||||
|
// so an empty `current` always accepts the next piece above.
|
||||||
|
flush();
|
||||||
|
current = piece;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
|
||||||
|
* a character (iterates code points, so surrogate pairs stay intact).
|
||||||
|
*/
|
||||||
|
function hardSplitByBytes(line: string, maxBytes: number): string[] {
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let currentBytes = 0;
|
||||||
|
|
||||||
|
for (const ch of line) {
|
||||||
|
const chBytes = Buffer.byteLength(ch, "utf8");
|
||||||
|
if (currentBytes + chBytes > maxBytes && current !== "") {
|
||||||
|
pieces.push(current);
|
||||||
|
current = "";
|
||||||
|
currentBytes = 0;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
currentBytes += chBytes;
|
||||||
|
}
|
||||||
|
if (current !== "") pieces.push(current);
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
+45
-1
@@ -10,7 +10,7 @@ import {
|
|||||||
renameSync,
|
renameSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
|
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig, defaultPlatform } from "./config.js";
|
||||||
|
|
||||||
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
|
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
|
||||||
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
|
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
|
||||||
@@ -48,6 +48,50 @@ describe("config", () => {
|
|||||||
expect(config).toEqual(getDefaultConfig());
|
expect(config).toEqual(getDefaultConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
|
||||||
|
const config = getDefaultConfig();
|
||||||
|
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||||
|
expect(config.enabledProviders).not.toContain("jellyfin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pre-gating behavior for legacy configs without enabledProviders", () => {
|
||||||
|
const dir = makeTmpDir();
|
||||||
|
const path = join(dir, "config.json");
|
||||||
|
// A config written before enabledProviders existed: no such field.
|
||||||
|
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
|
||||||
|
const config = loadConfig(path);
|
||||||
|
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||||
|
expect(defaultPlatform(config)).toBe("netease");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaultPlatform follows the fixed priority order", () => {
|
||||||
|
const config = getDefaultConfig();
|
||||||
|
expect(defaultPlatform(config)).toBe("netease");
|
||||||
|
|
||||||
|
// Jellyfin ranks after the online music platforms…
|
||||||
|
config.enabledProviders = ["netease", "jellyfin"];
|
||||||
|
expect(defaultPlatform(config)).toBe("netease");
|
||||||
|
// …but ahead of the video sites…
|
||||||
|
config.enabledProviders = ["bilibili", "jellyfin", "youtube"];
|
||||||
|
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||||
|
// …and is the default when it is the only enabled source.
|
||||||
|
config.enabledProviders = ["jellyfin"];
|
||||||
|
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||||
|
// Nothing enabled → netease fallback (the gate then reports it disabled).
|
||||||
|
config.enabledProviders = [];
|
||||||
|
expect(defaultPlatform(config)).toBe("netease");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit jellyfin-only enabledProviders from disk", () => {
|
||||||
|
const dir = makeTmpDir();
|
||||||
|
const path = join(dir, "config.json");
|
||||||
|
// e.g. a config persisted by the short-lived jellyfin-by-default builds.
|
||||||
|
writeFileSync(path, JSON.stringify({ enabledProviders: ["jellyfin"] }));
|
||||||
|
const config = loadConfig(path);
|
||||||
|
expect(config.enabledProviders).toEqual(["jellyfin"]);
|
||||||
|
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates config file on save", () => {
|
it("creates config file on save", () => {
|
||||||
const dir = makeTmpDir();
|
const dir = makeTmpDir();
|
||||||
const path = join(dir, "sub", "config.json");
|
const path = join(dir, "sub", "config.json");
|
||||||
|
|||||||
@@ -26,6 +26,56 @@ export interface SpotifyConfig {
|
|||||||
bitrate: number;
|
bitrate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinConfig {
|
||||||
|
/** Base URL of the Jellyfin server, e.g. "https://jellyfin.example.com". */
|
||||||
|
serverUrl: string;
|
||||||
|
authMode: "userpass" | "apikey";
|
||||||
|
// userpass mode
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
// apikey mode: admin API key + the user whose library/favorites/playlists are used
|
||||||
|
apiKey: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Providers gated by `enabledProviders`. Not listed here:
|
||||||
|
* - "local" → governed by the existing `localAudioEnabled` flag
|
||||||
|
* - "spotify" → governed by the existing `spotify.enabled` flag
|
||||||
|
*/
|
||||||
|
export const GATEABLE_PROVIDERS = [
|
||||||
|
"jellyfin",
|
||||||
|
"netease",
|
||||||
|
"qq",
|
||||||
|
"bilibili",
|
||||||
|
"youtube",
|
||||||
|
"kugou",
|
||||||
|
] as const;
|
||||||
|
export type GateableProvider = (typeof GATEABLE_PROVIDERS)[number];
|
||||||
|
|
||||||
|
/** Whether a platform may be used for search/playback under the current config. */
|
||||||
|
export function isProviderEnabled(config: BotConfig, platform: string): boolean {
|
||||||
|
if (platform === "local") return config.localAudioEnabled !== false;
|
||||||
|
if (platform === "spotify") return config.spotify.enabled;
|
||||||
|
return config.enabledProviders.includes(platform as GateableProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls:
|
||||||
|
* the first enabled provider in a fixed priority order (netease with the default
|
||||||
|
* config; jellyfin ranks after the online music platforms because it is an
|
||||||
|
* opt-in source, but ahead of the video sites for users who run it as their
|
||||||
|
* only music library). Falls back to "netease" when nothing is enabled so
|
||||||
|
* callers always get a provider — the enabled-gate then produces the friendly
|
||||||
|
* error.
|
||||||
|
*/
|
||||||
|
export function defaultPlatform(config: BotConfig): GateableProvider {
|
||||||
|
for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
|
||||||
|
if (config.enabledProviders.includes(p)) return p;
|
||||||
|
}
|
||||||
|
return "netease";
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotConfig {
|
export interface BotConfig {
|
||||||
webPort: number;
|
webPort: number;
|
||||||
locale: "zh" | "en";
|
locale: "zh" | "en";
|
||||||
@@ -51,6 +101,15 @@ export interface BotConfig {
|
|||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
guestMode: GuestModeConfig;
|
guestMode: GuestModeConfig;
|
||||||
spotify: SpotifyConfig;
|
spotify: SpotifyConfig;
|
||||||
|
jellyfin: JellyfinConfig;
|
||||||
|
/**
|
||||||
|
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
|
||||||
|
* the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
|
||||||
|
* opt-in extra that must be listed here (Settings → Jellyfin 音乐库 toggles
|
||||||
|
* it). Sources not listed stay disabled — the NetEase/QQ embedded sidecar
|
||||||
|
* API servers must not start (or bind ports 3001/3200) unless enabled.
|
||||||
|
*/
|
||||||
|
enabledProviders: GateableProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultConfig(): BotConfig {
|
export function getDefaultConfig(): BotConfig {
|
||||||
@@ -95,6 +154,15 @@ export function getDefaultConfig(): BotConfig {
|
|||||||
deviceName: "TSMusicBot",
|
deviceName: "TSMusicBot",
|
||||||
bitrate: 320,
|
bitrate: 320,
|
||||||
},
|
},
|
||||||
|
jellyfin: {
|
||||||
|
serverUrl: "",
|
||||||
|
authMode: "userpass",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
apiKey: "",
|
||||||
|
userId: "",
|
||||||
|
},
|
||||||
|
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,12 +289,42 @@ export function loadConfig(path: string): BotConfig {
|
|||||||
: defaults.spotify.bitrate,
|
: defaults.spotify.bitrate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sanitize the jellyfin block on load, mirroring the spotify handling: a
|
||||||
|
// hand-edited/legacy config.json must never smuggle wrong shapes past the
|
||||||
|
// gate. Unknown/invalid sub-fields fall back to defaults.
|
||||||
|
const partialJf = (partial.jellyfin ?? {}) as Partial<JellyfinConfig>;
|
||||||
|
const jellyfin: JellyfinConfig = {
|
||||||
|
serverUrl:
|
||||||
|
typeof partialJf.serverUrl === "string"
|
||||||
|
? partialJf.serverUrl.trim().replace(/\/+$/, "")
|
||||||
|
: defaults.jellyfin.serverUrl,
|
||||||
|
authMode:
|
||||||
|
partialJf.authMode === "apikey" ? "apikey" : defaults.jellyfin.authMode,
|
||||||
|
username:
|
||||||
|
typeof partialJf.username === "string" ? partialJf.username : defaults.jellyfin.username,
|
||||||
|
password:
|
||||||
|
typeof partialJf.password === "string" ? partialJf.password : defaults.jellyfin.password,
|
||||||
|
apiKey: typeof partialJf.apiKey === "string" ? partialJf.apiKey : defaults.jellyfin.apiKey,
|
||||||
|
userId: typeof partialJf.userId === "string" ? partialJf.userId : defaults.jellyfin.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// enabledProviders → known providers only; a non-array falls back to the
|
||||||
|
// default (online sources, jellyfin off). An explicitly-empty array is
|
||||||
|
// respected (operator chose to disable every gateable source).
|
||||||
|
const enabledProviders = Array.isArray(partial.enabledProviders)
|
||||||
|
? partial.enabledProviders.filter((p): p is GateableProvider =>
|
||||||
|
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
|
||||||
|
)
|
||||||
|
: defaults.enabledProviders;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...partial,
|
...partial,
|
||||||
adminGroups,
|
adminGroups,
|
||||||
guestMode: gm,
|
guestMode: gm,
|
||||||
spotify,
|
spotify,
|
||||||
|
jellyfin,
|
||||||
|
enabledProviders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe("database", () => {
|
|||||||
album: "Test Album",
|
album: "Test Album",
|
||||||
platform: "netease",
|
platform: "netease",
|
||||||
coverUrl: "https://example.com/cover.jpg",
|
coverUrl: "https://example.com/cover.jpg",
|
||||||
|
requestedBy: "alice",
|
||||||
});
|
});
|
||||||
|
|
||||||
botDb.addPlayHistory({
|
botDb.addPlayHistory({
|
||||||
@@ -76,6 +77,7 @@ describe("database", () => {
|
|||||||
expect(history).toHaveLength(2);
|
expect(history).toHaveLength(2);
|
||||||
expect(history[0].songName).toBe("Another Song");
|
expect(history[0].songName).toBe("Another Song");
|
||||||
expect(history[1].songName).toBe("Test Song");
|
expect(history[1].songName).toBe("Test Song");
|
||||||
|
expect(history[1].requestedBy).toBe("alice");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves and loads bot instances", () => {
|
it("saves and loads bot instances", () => {
|
||||||
|
|||||||
+12
-4
@@ -8,8 +8,9 @@ export interface PlayHistoryEntry {
|
|||||||
songName: string;
|
songName: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
requestedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayHistoryRecord extends PlayHistoryEntry {
|
export interface PlayHistoryRecord extends PlayHistoryEntry {
|
||||||
@@ -124,6 +125,12 @@ function migrateSchema(db: Database.Database): void {
|
|||||||
if (!userColNames.includes("role")) {
|
if (!userColNames.includes("role")) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
|
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
|
||||||
|
const historyColNames = historyColumns.map((c) => c.name);
|
||||||
|
if (!historyColNames.includes("requestedBy")) {
|
||||||
|
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTables(db: Database.Database): void {
|
function initTables(db: Database.Database): void {
|
||||||
@@ -137,6 +144,7 @@ function initTables(db: Database.Database): void {
|
|||||||
album TEXT NOT NULL,
|
album TEXT NOT NULL,
|
||||||
platform TEXT NOT NULL,
|
platform TEXT NOT NULL,
|
||||||
coverUrl TEXT NOT NULL,
|
coverUrl TEXT NOT NULL,
|
||||||
|
requestedBy TEXT NOT NULL DEFAULT '',
|
||||||
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -265,8 +273,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
ensureGuestUser(db);
|
ensureGuestUser(db);
|
||||||
|
|
||||||
const insertHistory = db.prepare(`
|
const insertHistory = db.prepare(`
|
||||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
|
||||||
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
|
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const selectHistory = db.prepare(`
|
const selectHistory = db.prepare(`
|
||||||
@@ -338,7 +346,7 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
db,
|
db,
|
||||||
|
|
||||||
addPlayHistory(record) {
|
addPlayHistory(record) {
|
||||||
insertHistory.run(record);
|
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
|
||||||
},
|
},
|
||||||
|
|
||||||
getPlayHistory(botId, limit) {
|
getPlayHistory(botId, limit) {
|
||||||
|
|||||||
+23
-3
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
|
import { loadConfig, saveConfig, migrateLegacyConfig, isProviderEnabled } from "./data/config.js";
|
||||||
import { createDatabase } from "./data/database.js";
|
import { createDatabase } from "./data/database.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { createApiServerManager } from "./music/api-server.js";
|
import { createApiServerManager } from "./music/api-server.js";
|
||||||
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
|
|||||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||||
import { LocalMusicProvider } from "./music/local.js";
|
import { LocalMusicProvider } from "./music/local.js";
|
||||||
import { KugouProvider } from "./music/kugou.js";
|
import { KugouProvider } from "./music/kugou.js";
|
||||||
|
import { JellyfinProvider } from "./music/jellyfin.js";
|
||||||
import { SpotifyProvider } from "./music/spotify/provider.js";
|
import { SpotifyProvider } from "./music/spotify/provider.js";
|
||||||
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
|
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
|
||||||
import { createCookieStore } from "./music/auth.js";
|
import { createCookieStore } from "./music/auth.js";
|
||||||
@@ -52,7 +53,14 @@ async function main() {
|
|||||||
const db = createDatabase(DB_PATH);
|
const db = createDatabase(DB_PATH);
|
||||||
|
|
||||||
const apiServer = createApiServerManager(
|
const apiServer = createApiServerManager(
|
||||||
{ neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort },
|
{
|
||||||
|
neteasePort: config.neteaseApiPort,
|
||||||
|
qqMusicPort: config.qqMusicApiPort,
|
||||||
|
// Provider gating: a sidecar only starts (and binds 3001/3200) when its
|
||||||
|
// source is listed in enabledProviders — both are on in the default config.
|
||||||
|
neteaseEnabled: isProviderEnabled(config, "netease"),
|
||||||
|
qqEnabled: isProviderEnabled(config, "qq"),
|
||||||
|
},
|
||||||
logger
|
logger
|
||||||
);
|
);
|
||||||
await apiServer.start();
|
await apiServer.start();
|
||||||
@@ -82,6 +90,16 @@ async function main() {
|
|||||||
const kugouCookie = cookieStore.load("kugou");
|
const kugouCookie = cookieStore.load("kugou");
|
||||||
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
||||||
|
|
||||||
|
// Jellyfin: admin-configured connection (no QR). The persisted blob carries
|
||||||
|
// AccessToken + User.Id + DeviceId and lives alongside the other platform
|
||||||
|
// cookies; a Jellyfin server that is unreachable at boot must not block
|
||||||
|
// startup — the provider authenticates lazily on first use.
|
||||||
|
const jellyfinProvider = new JellyfinProvider(logger);
|
||||||
|
jellyfinProvider.configure(config.jellyfin);
|
||||||
|
const jellyfinAuth = cookieStore.load("jellyfin");
|
||||||
|
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
|
||||||
|
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
|
||||||
|
|
||||||
const permissions = createPermissionStore(db.db);
|
const permissions = createPermissionStore(db.db);
|
||||||
|
|
||||||
// Single process-wide Spotify authorization (one Premium account for Stage 3).
|
// Single process-wide Spotify authorization (one Premium account for Stage 3).
|
||||||
@@ -114,7 +132,8 @@ async function main() {
|
|||||||
kugouProvider,
|
kugouProvider,
|
||||||
spotifyProvider,
|
spotifyProvider,
|
||||||
SPOTIFY_DATA_DIR,
|
SPOTIFY_DATA_DIR,
|
||||||
spotifyOAuth
|
spotifyOAuth,
|
||||||
|
jellyfinProvider
|
||||||
);
|
);
|
||||||
await botManager.loadSavedBots();
|
await botManager.loadSavedBots();
|
||||||
|
|
||||||
@@ -127,6 +146,7 @@ async function main() {
|
|||||||
localProvider,
|
localProvider,
|
||||||
kugouProvider,
|
kugouProvider,
|
||||||
spotifyProvider,
|
spotifyProvider,
|
||||||
|
jellyfinProvider,
|
||||||
database: db,
|
database: db,
|
||||||
avatarStore,
|
avatarStore,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { describeQqApiStartupError } from "./api-server.js";
|
import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
|
// Record every listen() the QQ sidecar makes so we can assert it is always
|
||||||
|
// pinned to the configured port (regression coverage for issue #122).
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
listenCalls: [] as Array<{ port: number; host: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@sansenjian/qq-music-api", () => {
|
||||||
|
const app = {
|
||||||
|
listen(port: number, host: string, cb?: () => void) {
|
||||||
|
mockState.listenCalls.push({ port, host });
|
||||||
|
const server = {
|
||||||
|
address: () => ({ port, address: host, family: "IPv4" as const }),
|
||||||
|
on() {
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
close(done?: () => void) {
|
||||||
|
done?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Real net/Koa fire the listening callback on a later tick, after the
|
||||||
|
// caller has captured the returned server handle.
|
||||||
|
if (cb) setImmediate(cb);
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { default: app };
|
||||||
|
});
|
||||||
|
|
||||||
describe("describeQqApiStartupError", () => {
|
describe("describeQqApiStartupError", () => {
|
||||||
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
||||||
@@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => {
|
|||||||
expect(describeQqApiStartupError(null)).toBeNull();
|
expect(describeQqApiStartupError(null)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
|
||||||
|
// the same port the client base URL targets (config.qqMusicApiPort). A stale
|
||||||
|
// build once bound 3300 while the client requested 3200, silently breaking the
|
||||||
|
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
|
||||||
|
describe("createApiServerManager — QQ sidecar port binding", () => {
|
||||||
|
const noopLogger = {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
debug() {},
|
||||||
|
trace() {},
|
||||||
|
fatal() {},
|
||||||
|
} as unknown as Logger;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockState.listenCalls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
|
||||||
|
const port = 39217; // uncommon port to avoid clashing with a real instance
|
||||||
|
const manager = createApiServerManager(
|
||||||
|
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||||
|
noopLogger
|
||||||
|
);
|
||||||
|
await manager.start();
|
||||||
|
manager.stop();
|
||||||
|
|
||||||
|
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
|
||||||
|
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
|
||||||
|
const port = 39219;
|
||||||
|
const previous = process.env.PORT;
|
||||||
|
// Simulate a hosting platform / compose file injecting a stray PORT that
|
||||||
|
// must NOT leak into the QQ sidecar's chosen port.
|
||||||
|
process.env.PORT = "39999";
|
||||||
|
const manager = createApiServerManager(
|
||||||
|
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||||
|
noopLogger
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await manager.start();
|
||||||
|
// The sidecar follows qqMusicPort, never the injected PORT.
|
||||||
|
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
|
||||||
|
// The injected PORT is restored so nothing else in the process is affected.
|
||||||
|
expect(process.env.PORT).toBe("39999");
|
||||||
|
} finally {
|
||||||
|
manager.stop();
|
||||||
|
if (previous === undefined) delete process.env.PORT;
|
||||||
|
else process.env.PORT = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an absent PORT env unset after importing the sidecar", async () => {
|
||||||
|
const port = 39221;
|
||||||
|
const previous = process.env.PORT;
|
||||||
|
delete process.env.PORT;
|
||||||
|
const manager = createApiServerManager(
|
||||||
|
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
|
||||||
|
noopLogger
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await manager.start();
|
||||||
|
// Was unset before importing — must be unset again, no leaked override.
|
||||||
|
expect(process.env.PORT).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
manager.stop();
|
||||||
|
if (previous === undefined) delete process.env.PORT;
|
||||||
|
else process.env.PORT = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+77
-28
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
|
|||||||
export interface ApiServerOptions {
|
export interface ApiServerOptions {
|
||||||
neteasePort: number;
|
neteasePort: number;
|
||||||
qqMusicPort: number;
|
qqMusicPort: number;
|
||||||
|
/** Provider gating (#enabledProviders): when false, the corresponding
|
||||||
|
* embedded sidecar API server is never started and its port never bound. */
|
||||||
|
neteaseEnabled?: boolean;
|
||||||
|
qqEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiServerManager {
|
export interface ApiServerManager {
|
||||||
@@ -65,34 +69,43 @@ export function createApiServerManager(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Provider gating: with the jellyfin-only default config neither legacy
|
||||||
|
// sidecar starts, so ports 3001/3200 are never opened.
|
||||||
|
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||||
|
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.info("Starting embedded music API servers...");
|
logger.info("Starting embedded music API servers...");
|
||||||
|
|
||||||
// Start NetEase Cloud Music API
|
// Start NetEase Cloud Music API
|
||||||
try {
|
if (options.neteaseEnabled !== false) {
|
||||||
const portFree = await isPortFree(options.neteasePort);
|
try {
|
||||||
if (!portFree) {
|
const portFree = await isPortFree(options.neteasePort);
|
||||||
logger.info(
|
if (!portFree) {
|
||||||
{ port: options.neteasePort },
|
logger.info(
|
||||||
"NetEase API port already in use — reusing existing instance"
|
{ port: options.neteasePort },
|
||||||
);
|
"NetEase API port already in use — reusing existing instance"
|
||||||
} else {
|
);
|
||||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
} else {
|
||||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||||
neteaseServer = app;
|
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||||
logger.info(
|
neteaseServer = app;
|
||||||
{ port: options.neteasePort },
|
logger.info(
|
||||||
"NetEase Cloud Music API started"
|
{ port: options.neteasePort },
|
||||||
);
|
"NetEase Cloud Music API started"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start QQ Music API. Older versions auto-started on import; the
|
// Start QQ Music API. Older versions auto-started on import; the
|
||||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||||
// so we explicitly call .listen() on the imported Koa app and keep
|
// so we explicitly call .listen() on the imported Koa app and keep
|
||||||
// the server handle for clean shutdown.
|
// the server handle for clean shutdown.
|
||||||
|
if (options.qqEnabled === false) return;
|
||||||
try {
|
try {
|
||||||
const portFree = await isPortFree(options.qqMusicPort);
|
const portFree = await isPortFree(options.qqMusicPort);
|
||||||
if (!portFree) {
|
if (!portFree) {
|
||||||
@@ -101,7 +114,25 @@ export function createApiServerManager(
|
|||||||
"QQ Music API port already in use — reusing existing instance"
|
"QQ Music API port already in use — reusing existing instance"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
// Pin the upstream server to the configured port before importing.
|
||||||
|
// The package derives its default port from process.env.PORT (falling
|
||||||
|
// back to 3200) and, in some historical versions, auto-started that
|
||||||
|
// server as an import side effect. Aligning PORT with qqMusicApiPort
|
||||||
|
// guarantees the sidecar can never bind a different port than the one
|
||||||
|
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
|
||||||
|
// issue #122, where an old build listened on 3300 while the client
|
||||||
|
// requested 3200. Restore the previous value right after import so we
|
||||||
|
// never leak the override into the rest of the process (e.g. the web
|
||||||
|
// server or the NetEase sidecar, which also read PORT as a fallback).
|
||||||
|
const prevPortEnv = process.env.PORT;
|
||||||
|
process.env.PORT = String(options.qqMusicPort);
|
||||||
|
let qqModule: any;
|
||||||
|
try {
|
||||||
|
qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||||
|
} finally {
|
||||||
|
if (prevPortEnv === undefined) delete process.env.PORT;
|
||||||
|
else process.env.PORT = prevPortEnv;
|
||||||
|
}
|
||||||
// The module's export structure varies between versions:
|
// The module's export structure varies between versions:
|
||||||
// 2.2.11+: default → Koa app (has .listen)
|
// 2.2.11+: default → Koa app (has .listen)
|
||||||
// 2.2.10: default → wrapper object whose .default is the Koa app
|
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||||
@@ -111,16 +142,34 @@ export function createApiServerManager(
|
|||||||
? candidate
|
? candidate
|
||||||
: candidate.default ?? null;
|
: candidate.default ?? null;
|
||||||
if (koaApp && typeof koaApp.listen === "function") {
|
if (koaApp && typeof koaApp.listen === "function") {
|
||||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
// A version that auto-started on import has already bound the
|
||||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
// configured port (thanks to the PORT alignment above); reuse it
|
||||||
resolve(srv)
|
// rather than racing a second listen that would fail EADDRINUSE.
|
||||||
|
const stillFree = await isPortFree(options.qqMusicPort);
|
||||||
|
if (!stillFree) {
|
||||||
|
logger.info(
|
||||||
|
{ port: options.qqMusicPort },
|
||||||
|
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
|
||||||
);
|
);
|
||||||
srv.on("error", reject);
|
} else {
|
||||||
});
|
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||||
logger.info(
|
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||||
{ port: options.qqMusicPort },
|
resolve(srv)
|
||||||
"QQ Music API started"
|
);
|
||||||
);
|
srv.on("error", reject);
|
||||||
|
});
|
||||||
|
// Log the port actually bound (read from the socket) rather than
|
||||||
|
// the requested one, so operators can spot a mismatch in the logs.
|
||||||
|
const addr = qqMusicServer.address();
|
||||||
|
const boundPort =
|
||||||
|
addr && typeof addr === "object" && addr !== null
|
||||||
|
? addr.port
|
||||||
|
: options.qqMusicPort;
|
||||||
|
logger.info(
|
||||||
|
{ port: boundPort },
|
||||||
|
"QQ Music API started"
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn("QQ Music API module does not expose a Koa app");
|
logger.warn("QQ Music API module does not expose a Koa app");
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -1,9 +1,13 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Platforms with a persisted credential blob. For jellyfin the "cookie" is a
|
||||||
|
* JSON string carrying the access token / userId / deviceId (see jellyfin.ts). */
|
||||||
|
type CookiePlatform = "netease" | "qq" | "bilibili" | "kugou" | "jellyfin";
|
||||||
|
|
||||||
export interface CookieStore {
|
export interface CookieStore {
|
||||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
|
save(platform: CookiePlatform, cookie: string): void;
|
||||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
|
load(platform: CookiePlatform): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCookieStore(cookieDir: string): CookieStore {
|
export function createCookieStore(cookieDir: string): CookieStore {
|
||||||
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
|
save(platform: CookiePlatform, cookie: string): void {
|
||||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -21,7 +25,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
|
load(platform: CookiePlatform): string {
|
||||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||||
if (!fs.existsSync(filePath)) return "";
|
if (!fs.existsSync(filePath)) return "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { BiliBiliProvider } from "./bilibili.js";
|
||||||
|
|
||||||
|
describe("BiliBiliProvider.search pagination", () => {
|
||||||
|
function mockProvider() {
|
||||||
|
const p = new BiliBiliProvider();
|
||||||
|
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
|
||||||
|
// Short-circuit the buvid + wbi bootstrap so search only issues the
|
||||||
|
// /search/type request we want to inspect.
|
||||||
|
(p as any).buvidInitialized = true;
|
||||||
|
(p as any).wbiMixinKey = "0".repeat(32);
|
||||||
|
(p as any).wbiKeyFetchedAt = Date.now();
|
||||||
|
(p as any).api = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchParams(get: ReturnType<typeof vi.fn>) {
|
||||||
|
const call = get.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
|
||||||
|
);
|
||||||
|
expect(call, "expected a /search/type call").toBeTruthy();
|
||||||
|
// signWbi stringifies every value.
|
||||||
|
return call![1].params as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds page (offset/limit+1) alongside page_size", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
const params = searchParams(get);
|
||||||
|
expect(params.page).toBe("2");
|
||||||
|
expect(params.page_size).toBe("20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20);
|
||||||
|
expect(searchParams(get).page).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
return fixed;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
await this.ensureBuvidCookie();
|
await this.ensureBuvidCookie();
|
||||||
await this.ensureWbiKeys();
|
await this.ensureWbiKeys();
|
||||||
|
// /search/type is page-based; the web pages in limit-aligned steps so
|
||||||
|
// offset is a multiple of page_size.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const signed = this.signWbi({
|
const signed = this.signWbi({
|
||||||
search_type: "video",
|
search_type: "video",
|
||||||
keyword: query,
|
keyword: query,
|
||||||
|
page,
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
});
|
});
|
||||||
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ticksToSeconds,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
buildCoverUrl,
|
||||||
|
buildStreamUrl,
|
||||||
|
buildUniversalUrl,
|
||||||
|
mapJellyfinSong,
|
||||||
|
mapJellyfinAlbum,
|
||||||
|
mapJellyfinPlaylist,
|
||||||
|
mapJellyfinLyrics,
|
||||||
|
describeJellyfinError,
|
||||||
|
JELLYFIN_QUALITY_LEVELS,
|
||||||
|
JellyfinProvider,
|
||||||
|
} from "./jellyfin.js";
|
||||||
|
import type { JellyfinConfig } from "../data/config.js";
|
||||||
|
|
||||||
|
function cfg(partial: Partial<JellyfinConfig> = {}): JellyfinConfig {
|
||||||
|
return {
|
||||||
|
serverUrl: "https://jf.example.com",
|
||||||
|
authMode: "apikey",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
apiKey: "KEY",
|
||||||
|
userId: "user-1",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider with a mocked axios instance; returns the request spy. */
|
||||||
|
function mockProvider(config = cfg(), responder?: (req: any) => any) {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
p.configure(config);
|
||||||
|
const request = vi.fn(async (req: any) => ({
|
||||||
|
data: responder ? responder(req) : { Items: [] },
|
||||||
|
}));
|
||||||
|
(p as any).api = { request, post: vi.fn() };
|
||||||
|
return { p, request };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Jellyfin mapping helpers", () => {
|
||||||
|
it("converts ticks to seconds (1 tick = 100ns)", () => {
|
||||||
|
expect(ticksToSeconds(TICKS_PER_SECOND)).toBe(1);
|
||||||
|
expect(ticksToSeconds(2_275_000_000)).toBeCloseTo(227.5, 3);
|
||||||
|
expect(ticksToSeconds(undefined)).toBe(0);
|
||||||
|
expect(ticksToSeconds(NaN)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a Jellyfin audio item to Song (GUID id stays a string)", () => {
|
||||||
|
const song = mapJellyfinSong(
|
||||||
|
{
|
||||||
|
Id: "3fa85f6457174562b3fc2c963f66afa6",
|
||||||
|
Name: "Track",
|
||||||
|
Artists: ["A", "B"],
|
||||||
|
AlbumArtist: "A",
|
||||||
|
Album: "The Album",
|
||||||
|
RunTimeTicks: 1_855_000_000,
|
||||||
|
},
|
||||||
|
"https://jf/x.jpg",
|
||||||
|
);
|
||||||
|
expect(song).toEqual({
|
||||||
|
id: "3fa85f6457174562b3fc2c963f66afa6",
|
||||||
|
name: "Track",
|
||||||
|
artist: "A / B",
|
||||||
|
album: "The Album",
|
||||||
|
duration: 186, // 185.5s rounded
|
||||||
|
coverUrl: "https://jf/x.jpg",
|
||||||
|
platform: "jellyfin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to AlbumArtist when Artists is empty", () => {
|
||||||
|
const song = mapJellyfinSong({ Id: "1", Artists: [], AlbumArtist: "Solo" }, "");
|
||||||
|
expect(song.artist).toBe("Solo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps albums and playlists with ChildCount", () => {
|
||||||
|
const album = mapJellyfinAlbum(
|
||||||
|
{ Id: "a1", Name: "LP", AlbumArtist: "X", ChildCount: 10 },
|
||||||
|
"c",
|
||||||
|
);
|
||||||
|
expect(album).toMatchObject({ id: "a1", artist: "X", songCount: 10, platform: "jellyfin" });
|
||||||
|
const pl = mapJellyfinPlaylist({ Id: "p1", Name: "Mix", ChildCount: 7 }, "c");
|
||||||
|
expect(pl).toMatchObject({ id: "p1", songCount: 7, platform: "jellyfin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cover URL prefers the item's Primary image, falls back to the album's", () => {
|
||||||
|
const own = buildCoverUrl("https://jf", "K", {
|
||||||
|
Id: "i1",
|
||||||
|
ImageTags: { Primary: "tag1" },
|
||||||
|
AlbumId: "a1",
|
||||||
|
AlbumPrimaryImageTag: "tag2",
|
||||||
|
});
|
||||||
|
expect(own).toBe("https://jf/Items/i1/Images/Primary?maxWidth=512&tag=tag1&api_key=K");
|
||||||
|
const album = buildCoverUrl("https://jf", "K", {
|
||||||
|
Id: "i1",
|
||||||
|
AlbumId: "a1",
|
||||||
|
AlbumPrimaryImageTag: "tag2",
|
||||||
|
});
|
||||||
|
expect(album).toBe("https://jf/Items/a1/Images/Primary?maxWidth=512&tag=tag2&api_key=K");
|
||||||
|
expect(buildCoverUrl("https://jf", "K", { Id: "i1" })).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds direct and transcoded stream URLs", () => {
|
||||||
|
expect(buildStreamUrl("https://jf", "K", "i1")).toBe(
|
||||||
|
"https://jf/Audio/i1/stream?static=true&api_key=K",
|
||||||
|
);
|
||||||
|
const url = buildUniversalUrl("https://jf", {
|
||||||
|
apiKey: "K",
|
||||||
|
userId: "u",
|
||||||
|
deviceId: "d",
|
||||||
|
itemId: "i1",
|
||||||
|
kbps: 320,
|
||||||
|
});
|
||||||
|
expect(url).toContain("https://jf/Audio/i1/universal?");
|
||||||
|
expect(url).toContain("maxStreamingBitrate=320000");
|
||||||
|
expect(url).toContain("transcodingContainer=mp3");
|
||||||
|
expect(url).toContain("transcodingProtocol=http");
|
||||||
|
// The container list must be URL-encoded ("|" → %7C)
|
||||||
|
expect(url).toContain("container=mp3%2Caac%2Cm4a%7Caac%2Cflac%2Cwebma%2Cwebm%2Cwav%2Cogg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps lyrics with tick offsets; entries without Start collapse to 0; empty text dropped", () => {
|
||||||
|
const lines = mapJellyfinLyrics({
|
||||||
|
Lyrics: [
|
||||||
|
{ Text: "line two", Start: 125_000_000 },
|
||||||
|
{ Text: "line one", Start: 5_000_000 },
|
||||||
|
{ Text: " ", Start: 1 },
|
||||||
|
{ Text: "untimed" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ time: 0, text: "untimed" },
|
||||||
|
{ time: 0.5, text: "line one" },
|
||||||
|
{ time: 12.5, text: "line two" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapJellyfinLyrics tolerates junk payloads", () => {
|
||||||
|
expect(mapJellyfinLyrics(null)).toEqual([]);
|
||||||
|
expect(mapJellyfinLyrics({})).toEqual([]);
|
||||||
|
expect(mapJellyfinLyrics({ Lyrics: "nope" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes common connection errors in a friendly way", () => {
|
||||||
|
expect(describeJellyfinError({ response: { status: 401 } })).toContain("401");
|
||||||
|
expect(describeJellyfinError({ code: "ECONNREFUSED" })).toContain("无法连接");
|
||||||
|
expect(describeJellyfinError(new Error("boom"))).toBe("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JellyfinProvider", () => {
|
||||||
|
it("quality: defaults to direct, ignores foreign (NetEase) values", () => {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
expect(p.getQuality()).toBe("direct");
|
||||||
|
p.setQuality("exhigh"); // NetEase value from the legacy platform-less broadcast
|
||||||
|
expect(p.getQuality()).toBe("direct");
|
||||||
|
p.setQuality("320");
|
||||||
|
expect(p.getQuality()).toBe("320");
|
||||||
|
expect(JELLYFIN_QUALITY_LEVELS[0].value).toBe("direct");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSongUrl: direct tier → static stream; 320 tier → universal transcode", async () => {
|
||||||
|
const { p } = mockProvider();
|
||||||
|
p.setCookie(JSON.stringify({ deviceId: "dev-1" }));
|
||||||
|
const direct = await p.getSongUrl("item1");
|
||||||
|
expect(direct?.url).toBe("https://jf.example.com/Audio/item1/stream?static=true&api_key=KEY");
|
||||||
|
|
||||||
|
p.setQuality("320");
|
||||||
|
const transcoded = await p.getSongUrl("item1");
|
||||||
|
expect(transcoded?.url).toContain("/Audio/item1/universal?");
|
||||||
|
expect(transcoded?.url).toContain("maxStreamingBitrate=320000");
|
||||||
|
expect(transcoded?.url).toContain("api_key=KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search hits /Items for Audio, MusicAlbum and Playlist with paging", async () => {
|
||||||
|
const { p, request } = mockProvider();
|
||||||
|
await p.search("mozart", 30, 60);
|
||||||
|
const types = request.mock.calls.map((c) => c[0].params?.IncludeItemTypes);
|
||||||
|
expect(types).toContain("Audio");
|
||||||
|
expect(types).toContain("MusicAlbum");
|
||||||
|
expect(types).toContain("Playlist");
|
||||||
|
for (const call of request.mock.calls) {
|
||||||
|
expect(call[0].params.Limit).toBe(30);
|
||||||
|
expect(call[0].params.StartIndex).toBe(60);
|
||||||
|
expect(call[0].params.userId).toBe("user-1");
|
||||||
|
expect(call[0].headers["X-Emby-Token"]).toBe("KEY");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("userpass: authenticates once via AuthenticateByName, persists token, retries once on 401", async () => {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
p.configure(cfg({ authMode: "userpass", username: "eric", password: "pw", apiKey: "", userId: "" }));
|
||||||
|
const persisted: string[] = [];
|
||||||
|
p.setPersist((s) => persisted.push(s));
|
||||||
|
|
||||||
|
let tokenCounter = 0;
|
||||||
|
const post = vi.fn(async (..._args: any[]) => ({
|
||||||
|
data: { AccessToken: `tok-${++tokenCounter}`, User: { Id: "u9" } },
|
||||||
|
}));
|
||||||
|
// First data request 401s (expired token), the retry succeeds.
|
||||||
|
let dataCalls = 0;
|
||||||
|
const request = vi.fn(async (req: any) => {
|
||||||
|
dataCalls++;
|
||||||
|
if (dataCalls === 1) {
|
||||||
|
const err: any = new Error("Unauthorized");
|
||||||
|
err.response = { status: 401 };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { data: { Items: [{ Id: "s1", Name: "N" }] }, config: req };
|
||||||
|
});
|
||||||
|
(p as any).api = { request, post };
|
||||||
|
|
||||||
|
const songs = await p.getPlaylistSongs("pl1");
|
||||||
|
expect(songs).toHaveLength(1);
|
||||||
|
// login ran twice: initial auth + re-auth after the 401
|
||||||
|
expect(post).toHaveBeenCalledTimes(2);
|
||||||
|
const authHeader = post.mock.calls[0][2].headers.Authorization as string;
|
||||||
|
expect(authHeader).toContain('MediaBrowser Client="TSMusicBot"');
|
||||||
|
expect(authHeader).toContain("DeviceId=");
|
||||||
|
expect(post.mock.calls[0][1]).toEqual({ Username: "eric", Pw: "pw" });
|
||||||
|
// token + userId + deviceId persisted like cookies
|
||||||
|
const last = JSON.parse(persisted[persisted.length - 1]);
|
||||||
|
expect(last.accessToken).toBe("tok-2");
|
||||||
|
expect(last.userId).toBe("u9");
|
||||||
|
expect(last.deviceId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("userpass: surfaces the error when the retry also 401s (single retry only)", async () => {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
p.configure(cfg({ authMode: "userpass", username: "eric", password: "bad", apiKey: "" }));
|
||||||
|
const post = vi.fn(async () => ({ data: { AccessToken: "t", User: { Id: "u" } } }));
|
||||||
|
const err: any = new Error("Unauthorized");
|
||||||
|
err.response = { status: 401 };
|
||||||
|
const request = vi.fn(async () => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
(p as any).api = { request, post };
|
||||||
|
await expect(p.getPlaylistSongs("pl1")).rejects.toThrow("Unauthorized");
|
||||||
|
expect(request).toHaveBeenCalledTimes(2); // original + exactly one retry
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCookie/getCookie round-trip restores token, userId and deviceId", () => {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
|
||||||
|
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
|
||||||
|
p.setCookie("not json"); // junk must not corrupt state
|
||||||
|
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getLyrics: 404 means no lyrics (empty), other errors surface", async () => {
|
||||||
|
const notFound: any = new Error("nf");
|
||||||
|
notFound.response = { status: 404 };
|
||||||
|
const { p } = mockProvider(cfg(), () => {
|
||||||
|
throw notFound;
|
||||||
|
});
|
||||||
|
expect(await p.getLyrics("i1")).toEqual([]);
|
||||||
|
|
||||||
|
const boom: any = new Error("server down");
|
||||||
|
boom.response = { status: 500 };
|
||||||
|
const { p: p2 } = mockProvider(cfg(), () => {
|
||||||
|
throw boom;
|
||||||
|
});
|
||||||
|
await expect(p2.getLyrics("i1")).rejects.toThrow("server down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAuthStatus reflects /System/Info reachability; unconfigured = logged out", async () => {
|
||||||
|
const blank = new JellyfinProvider();
|
||||||
|
expect(await blank.getAuthStatus()).toEqual({ loggedIn: false });
|
||||||
|
|
||||||
|
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS" }));
|
||||||
|
expect(await p.getAuthStatus()).toEqual({ loggedIn: true, nickname: "API Key@NAS" });
|
||||||
|
|
||||||
|
const { p: down } = mockProvider(cfg(), () => {
|
||||||
|
throw new Error("ECONNREFUSED");
|
||||||
|
});
|
||||||
|
expect(await down.getAuthStatus()).toEqual({ loggedIn: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPersonalFm seeds InstantMix from a random favorite", async () => {
|
||||||
|
const { p, request } = mockProvider(cfg(), (req: any) => {
|
||||||
|
if (req.url.includes("/InstantMix")) {
|
||||||
|
return { Items: [{ Id: "m1", Name: "Mixed" }] };
|
||||||
|
}
|
||||||
|
if (req.params?.Filters === "IsFavorite") {
|
||||||
|
return { Items: [{ Id: "fav1", Name: "Fav" }] };
|
||||||
|
}
|
||||||
|
return { Items: [] };
|
||||||
|
});
|
||||||
|
const songs = await p.getPersonalFm();
|
||||||
|
expect(songs.map((s) => s.id)).toEqual(["m1"]);
|
||||||
|
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
|
||||||
|
expect(mixCall![0].url).toContain("/Items/fav1/InstantMix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getPersonalFm falls back: favorites → recently played → random", async () => {
|
||||||
|
const { p, request } = mockProvider(cfg(), (req: any) => {
|
||||||
|
if (req.url.includes("/InstantMix")) return { Items: [{ Id: "m2" }] };
|
||||||
|
if (req.params?.Filters === "IsFavorite") return { Items: [] };
|
||||||
|
if (req.params?.Filters === "IsPlayed") return { Items: [{ Id: "recent1" }] };
|
||||||
|
return { Items: [] };
|
||||||
|
});
|
||||||
|
const songs = await p.getPersonalFm();
|
||||||
|
expect(songs.map((s) => s.id)).toEqual(["m2"]);
|
||||||
|
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
|
||||||
|
expect(mixCall![0].url).toContain("/Items/recent1/InstantMix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("playback reporter posts start/progress/stopped with PositionTicks", async () => {
|
||||||
|
const bodies: { url: string; data: any }[] = [];
|
||||||
|
const { p } = mockProvider(cfg(), () => ({}));
|
||||||
|
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
async (req: any) => {
|
||||||
|
bodies.push({ url: req.url, data: req.data });
|
||||||
|
return { data: {} };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const reporter = p.createPlaybackReporter();
|
||||||
|
reporter.onTrackStart("song1");
|
||||||
|
reporter.onTick("song1", 90.5, false);
|
||||||
|
reporter.onStop();
|
||||||
|
await vi.waitFor(() => expect(bodies).toHaveLength(3));
|
||||||
|
|
||||||
|
expect(bodies[0].url).toContain("/Sessions/Playing");
|
||||||
|
expect(bodies[0].data.ItemId).toBe("song1");
|
||||||
|
const sessionId = bodies[0].data.PlaySessionId;
|
||||||
|
expect(sessionId).toBeTruthy();
|
||||||
|
|
||||||
|
expect(bodies[1].url).toContain("/Sessions/Playing/Progress");
|
||||||
|
expect(bodies[1].data.PositionTicks).toBe(905_000_000);
|
||||||
|
expect(bodies[1].data.PlaySessionId).toBe(sessionId);
|
||||||
|
|
||||||
|
// Stop carries the LAST reported position so a natural track end counts as played.
|
||||||
|
expect(bodies[2].url).toContain("/Sessions/Playing/Stopped");
|
||||||
|
expect(bodies[2].data.PositionTicks).toBe(905_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reporter: a new track start closes the previous session; failures are swallowed", async () => {
|
||||||
|
const bodies: { url: string; data: any }[] = [];
|
||||||
|
const { p } = mockProvider(cfg(), () => ({}));
|
||||||
|
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
async (req: any) => {
|
||||||
|
bodies.push({ url: req.url, data: req.data });
|
||||||
|
if (req.url.includes("/Sessions")) throw new Error("reporting endpoint down");
|
||||||
|
return { data: {} };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const reporter = p.createPlaybackReporter();
|
||||||
|
reporter.onTrackStart("a");
|
||||||
|
reporter.onTick("a", 200, false);
|
||||||
|
reporter.onTrackStart("b"); // must post Stopped for "a" at pos 200, then Playing for "b"
|
||||||
|
reporter.onStop();
|
||||||
|
await vi.waitFor(() => expect(bodies.length).toBeGreaterThanOrEqual(5));
|
||||||
|
|
||||||
|
const stopped = bodies.filter((b) => b.url.includes("/Stopped"));
|
||||||
|
expect(stopped[0].data.ItemId).toBe("a");
|
||||||
|
expect(stopped[0].data.PositionTicks).toBe(2_000_000_000);
|
||||||
|
expect(stopped[1].data.ItemId).toBe("b");
|
||||||
|
// ticks for stale items are ignored
|
||||||
|
reporter.onTick("a", 999, false);
|
||||||
|
expect(bodies.filter((b) => b.url.includes("/Progress"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("testConnection reports server info on success and friendly errors on failure", async () => {
|
||||||
|
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS", Version: "10.9.11" }));
|
||||||
|
expect(await p.testConnection()).toEqual({ ok: true, serverName: "NAS", version: "10.9.11" });
|
||||||
|
|
||||||
|
const bad = new JellyfinProvider();
|
||||||
|
bad.configure(cfg({ apiKey: "", userId: "" }));
|
||||||
|
const res = await bad.testConnection();
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error).toContain("apiKey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configure drops the cached token when credentials change, keeps deviceId", () => {
|
||||||
|
const p = new JellyfinProvider();
|
||||||
|
p.configure(cfg());
|
||||||
|
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
|
||||||
|
p.configure(cfg({ serverUrl: "https://other.example.com" }));
|
||||||
|
const state = JSON.parse(p.getCookie());
|
||||||
|
expect(state.accessToken).toBe("");
|
||||||
|
expect(state.deviceId).toBe("D");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,741 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||||
|
import type { JellyfinConfig } from "../data/config.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
import type {
|
||||||
|
MusicProvider,
|
||||||
|
Song,
|
||||||
|
SongUrlResult,
|
||||||
|
Playlist,
|
||||||
|
PlaylistDetail,
|
||||||
|
LyricLine,
|
||||||
|
SearchResult,
|
||||||
|
QrCodeResult,
|
||||||
|
AuthStatus,
|
||||||
|
Album,
|
||||||
|
} from "./provider.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** 1 tick = 100 ns → 10,000,000 ticks = 1 second (Jellyfin RunTimeTicks / lyric Start). */
|
||||||
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
|
||||||
|
export function ticksToSeconds(ticks: number | null | undefined): number {
|
||||||
|
if (typeof ticks !== "number" || !Number.isFinite(ticks)) return 0;
|
||||||
|
return ticks / TICKS_PER_SECOND;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_NAME = "TSMusicBot";
|
||||||
|
|
||||||
|
/** Best-effort package version for the MediaBrowser auth header. */
|
||||||
|
const PKG_VERSION = (() => {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
|
||||||
|
) as { version?: string };
|
||||||
|
return pkg.version ?? "0.0.0";
|
||||||
|
} catch {
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Jellyfin quality tiers replace the NetEase labels for this source. "direct"
|
||||||
|
// (default) streams the original file — the bot re-encodes to Opus anyway, so
|
||||||
|
// this is max quality. The transcode tiers exist for remote/low-bandwidth
|
||||||
|
// Jellyfin servers.
|
||||||
|
export const JELLYFIN_QUALITY_LEVELS = [
|
||||||
|
{ value: "direct", label: "原始直传 Direct", bitrate: 0 },
|
||||||
|
{ value: "320", label: "320kbps 转码", bitrate: 320 },
|
||||||
|
{ value: "192", label: "192kbps 转码", bitrate: 192 },
|
||||||
|
{ value: "128", label: "128kbps 转码", bitrate: 128 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Subset of Jellyfin's BaseItemDto that this provider consumes. */
|
||||||
|
export interface JellyfinItem {
|
||||||
|
Id: string;
|
||||||
|
Name?: string;
|
||||||
|
Artists?: string[];
|
||||||
|
AlbumArtist?: string;
|
||||||
|
Album?: string;
|
||||||
|
AlbumId?: string;
|
||||||
|
AlbumPrimaryImageTag?: string;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
ImageTags?: { Primary?: string };
|
||||||
|
ChildCount?: number;
|
||||||
|
SongCount?: number;
|
||||||
|
Overview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary cover art URL for an item; falls back to the parent album's primary
|
||||||
|
* image (AlbumPrimaryImageTag) and finally to "" (the UI renders a placeholder
|
||||||
|
* for missing art). The api_key query param authenticates browser <img> loads
|
||||||
|
* and the TS-avatar download alike.
|
||||||
|
*/
|
||||||
|
export function buildCoverUrl(baseUrl: string, apiKey: string, item: JellyfinItem): string {
|
||||||
|
if (!baseUrl) return "";
|
||||||
|
const key = apiKey ? `&api_key=${encodeURIComponent(apiKey)}` : "";
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
return `${baseUrl}/Items/${item.Id}/Images/Primary?maxWidth=512&tag=${item.ImageTags.Primary}${key}`;
|
||||||
|
}
|
||||||
|
if (item.AlbumId && item.AlbumPrimaryImageTag) {
|
||||||
|
return `${baseUrl}/Items/${item.AlbumId}/Images/Primary?maxWidth=512&tag=${item.AlbumPrimaryImageTag}${key}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct (untranscoded) stream URL — the default playback path. */
|
||||||
|
export function buildStreamUrl(baseUrl: string, apiKey: string, itemId: string): string {
|
||||||
|
const params = new URLSearchParams({ static: "true", api_key: apiKey });
|
||||||
|
return `${baseUrl}/Audio/${itemId}/stream?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side transcode URL for the 320/192/128 kbps tiers. */
|
||||||
|
export function buildUniversalUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
o: { apiKey: string; userId: string; deviceId: string; itemId: string; kbps: number },
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
userId: o.userId,
|
||||||
|
deviceId: o.deviceId,
|
||||||
|
api_key: o.apiKey,
|
||||||
|
maxStreamingBitrate: String(o.kbps * 1000),
|
||||||
|
container: "mp3,aac,m4a|aac,flac,webma,webm,wav,ogg",
|
||||||
|
transcodingContainer: "mp3",
|
||||||
|
transcodingProtocol: "http",
|
||||||
|
});
|
||||||
|
return `${baseUrl}/Audio/${o.itemId}/universal?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapJellyfinSong(item: JellyfinItem, coverUrl: string): Song {
|
||||||
|
return {
|
||||||
|
id: String(item.Id),
|
||||||
|
name: item.Name ?? "",
|
||||||
|
artist: (item.Artists?.length ? item.Artists : [item.AlbumArtist ?? ""])
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / "),
|
||||||
|
album: item.Album ?? "",
|
||||||
|
duration: Math.round(ticksToSeconds(item.RunTimeTicks)),
|
||||||
|
coverUrl,
|
||||||
|
platform: "jellyfin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapJellyfinAlbum(item: JellyfinItem, coverUrl: string): Album {
|
||||||
|
return {
|
||||||
|
id: String(item.Id),
|
||||||
|
name: item.Name ?? "",
|
||||||
|
artist: item.AlbumArtist ?? (item.Artists ?? []).join(" / "),
|
||||||
|
coverUrl,
|
||||||
|
songCount: item.ChildCount ?? item.SongCount ?? 0,
|
||||||
|
platform: "jellyfin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapJellyfinPlaylist(item: JellyfinItem, coverUrl: string): Playlist {
|
||||||
|
return {
|
||||||
|
id: String(item.Id),
|
||||||
|
name: item.Name ?? "",
|
||||||
|
coverUrl,
|
||||||
|
songCount: item.ChildCount ?? item.SongCount ?? 0,
|
||||||
|
platform: "jellyfin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map `GET /Audio/{id}/Lyrics` → the bot's synced-lyric format. `Start` is in
|
||||||
|
* ticks; entries without Start (plain-text lyrics) collapse to time 0 so the
|
||||||
|
* lyric view still renders them, just without sync. Translation stays empty —
|
||||||
|
* Jellyfin has no translated-lyrics concept.
|
||||||
|
*/
|
||||||
|
export function mapJellyfinLyrics(payload: unknown): LyricLine[] {
|
||||||
|
const lyrics = (payload as { Lyrics?: { Text?: string; Start?: number }[] })?.Lyrics;
|
||||||
|
if (!Array.isArray(lyrics)) return [];
|
||||||
|
return lyrics
|
||||||
|
.filter((l) => typeof l?.Text === "string" && l.Text.trim() !== "")
|
||||||
|
.map((l) => ({ time: ticksToSeconds(l.Start), text: l.Text!.trim() }))
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator-friendly connection/auth error description (test-connection UI + logs). */
|
||||||
|
export function describeJellyfinError(err: unknown): string {
|
||||||
|
const e = err as AxiosError;
|
||||||
|
if (e?.response) {
|
||||||
|
const s = e.response.status;
|
||||||
|
if (s === 401) return "认证失败:用户名/密码或 API Key 不正确 (401 Unauthorized)";
|
||||||
|
if (s === 403) return "该账号没有访问权限 (403 Forbidden)";
|
||||||
|
if (s === 404) return "接口不存在——请确认地址指向 Jellyfin 根路径 (404 Not Found)";
|
||||||
|
return `Jellyfin 返回 HTTP ${s}`;
|
||||||
|
}
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code === "ECONNREFUSED") return "无法连接 Jellyfin 服务器 (connection refused)";
|
||||||
|
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "无法解析服务器地址 (DNS lookup failed)";
|
||||||
|
if (code === "ETIMEDOUT" || code === "ECONNABORTED") return "连接 Jellyfin 超时 (timeout)";
|
||||||
|
return (err as Error)?.message ?? String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-bot playback reporting handle. One playback session (PlaySessionId) per
|
||||||
|
* track start; the previous session is closed with its last known position so
|
||||||
|
* Jellyfin's played/PlayCount bookkeeping sees natural track ends as ~complete
|
||||||
|
* plays and mid-track skips as partial ones. Every call is fire-and-forget —
|
||||||
|
* reporting must never affect playback.
|
||||||
|
*/
|
||||||
|
export interface JellyfinPlaybackReporter {
|
||||||
|
onTrackStart(itemId: string): void;
|
||||||
|
onTick(itemId: string, positionSec: number, paused: boolean): void;
|
||||||
|
onStop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedAuth {
|
||||||
|
accessToken?: string;
|
||||||
|
userId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyConfig(): JellyfinConfig {
|
||||||
|
return { serverUrl: "", authMode: "userpass", username: "", password: "", apiKey: "", userId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JellyfinProvider implements MusicProvider {
|
||||||
|
readonly platform = "jellyfin" as const;
|
||||||
|
private api: AxiosInstance;
|
||||||
|
private cfg: JellyfinConfig = emptyConfig();
|
||||||
|
private token = "";
|
||||||
|
private userId = "";
|
||||||
|
private deviceId = "";
|
||||||
|
private quality: string = "direct";
|
||||||
|
private loginPromise: Promise<void> | null = null;
|
||||||
|
private persistFn: ((serialized: string) => void) | null = null;
|
||||||
|
private logger: Logger | null = null;
|
||||||
|
|
||||||
|
constructor(logger?: Logger) {
|
||||||
|
this.api = axios.create({ timeout: 10000 });
|
||||||
|
this.logger = logger ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply (or re-apply, on settings save) the admin-configured connection.
|
||||||
|
* Changing any credential-relevant field drops the cached token so the next
|
||||||
|
* request re-authenticates; the stable deviceId survives reconfiguration.
|
||||||
|
*/
|
||||||
|
configure(cfg: JellyfinConfig): void {
|
||||||
|
const prev = this.cfg;
|
||||||
|
this.cfg = { ...cfg, serverUrl: (cfg.serverUrl ?? "").trim().replace(/\/+$/, "") };
|
||||||
|
const credsChanged =
|
||||||
|
prev.serverUrl !== this.cfg.serverUrl ||
|
||||||
|
prev.authMode !== this.cfg.authMode ||
|
||||||
|
prev.username !== this.cfg.username ||
|
||||||
|
prev.password !== this.cfg.password ||
|
||||||
|
prev.apiKey !== this.cfg.apiKey ||
|
||||||
|
prev.userId !== this.cfg.userId;
|
||||||
|
if (credsChanged) {
|
||||||
|
this.token = "";
|
||||||
|
this.userId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return this.cfg.serverUrl.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire the on-disk persistence used after a successful login (cookie store). */
|
||||||
|
setPersist(fn: (serialized: string) => void): void {
|
||||||
|
this.persistFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuality(quality: string): void {
|
||||||
|
// Ignore foreign values: the legacy platform-less POST /api/music/quality
|
||||||
|
// broadcasts NetEase levels to every provider, which must not clobber the
|
||||||
|
// jellyfin default ("direct").
|
||||||
|
if (JELLYFIN_QUALITY_LEVELS.some((l) => l.value === quality)) {
|
||||||
|
this.quality = quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getQuality(): string {
|
||||||
|
return this.quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
private authHeader(): string {
|
||||||
|
return (
|
||||||
|
`MediaBrowser Client="${CLIENT_NAME}", Device="${CLIENT_NAME}", ` +
|
||||||
|
`DeviceId="${this.deviceId}", Version="${PKG_VERSION}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDeviceId(): void {
|
||||||
|
if (!this.deviceId) {
|
||||||
|
this.deviceId = crypto.randomUUID();
|
||||||
|
this.persistState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistState(): void {
|
||||||
|
try {
|
||||||
|
this.persistFn?.(this.getCookie());
|
||||||
|
} catch (err) {
|
||||||
|
this.logger?.warn({ err }, "Failed to persist Jellyfin auth state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
this.ensureDeviceId();
|
||||||
|
const res = await this.api.post(
|
||||||
|
`${this.cfg.serverUrl}/Users/AuthenticateByName`,
|
||||||
|
{ Username: this.cfg.username, Pw: this.cfg.password },
|
||||||
|
{ headers: { Authorization: this.authHeader() } },
|
||||||
|
);
|
||||||
|
const token = res.data?.AccessToken as string | undefined;
|
||||||
|
const userId = res.data?.User?.Id as string | undefined;
|
||||||
|
if (!token || !userId) {
|
||||||
|
throw new Error("Jellyfin 登录响应缺少 AccessToken/User.Id");
|
||||||
|
}
|
||||||
|
this.token = token;
|
||||||
|
this.userId = userId;
|
||||||
|
this.persistState();
|
||||||
|
this.logger?.info({ userId }, "Jellyfin authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureAuth(): Promise<void> {
|
||||||
|
if (!this.cfg.serverUrl) {
|
||||||
|
throw new Error("Jellyfin 未配置服务器地址 (server URL not configured)");
|
||||||
|
}
|
||||||
|
if (this.cfg.authMode === "apikey") {
|
||||||
|
if (!this.cfg.apiKey || !this.cfg.userId) {
|
||||||
|
throw new Error("Jellyfin API Key 模式需要 apiKey 和 userId");
|
||||||
|
}
|
||||||
|
this.token = this.cfg.apiKey;
|
||||||
|
this.userId = this.cfg.userId;
|
||||||
|
this.ensureDeviceId();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.token && this.userId) return;
|
||||||
|
if (!this.cfg.username) {
|
||||||
|
throw new Error("Jellyfin 未配置用户名/密码 (username not configured)");
|
||||||
|
}
|
||||||
|
if (!this.loginPromise) {
|
||||||
|
this.loginPromise = this.login().finally(() => {
|
||||||
|
this.loginPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated request with a single re-auth retry on 401 (userpass mode). */
|
||||||
|
private async request<T = unknown>(
|
||||||
|
method: "get" | "post",
|
||||||
|
path: string,
|
||||||
|
opts: { params?: Record<string, unknown>; data?: unknown; retried?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
await this.ensureAuth();
|
||||||
|
// Callers snapshot `userId: this.userId` while building params, which is
|
||||||
|
// still empty before the first login. ensureAuth() has populated it by
|
||||||
|
// now, so backfill the stale-empty snapshot instead of sending userId="".
|
||||||
|
const params = opts.params ? { ...opts.params } : undefined;
|
||||||
|
if (params && "userId" in params && !params.userId) params.userId = this.userId;
|
||||||
|
try {
|
||||||
|
const res = await this.api.request<T>({
|
||||||
|
method,
|
||||||
|
url: this.cfg.serverUrl + path,
|
||||||
|
params,
|
||||||
|
data: opts.data,
|
||||||
|
headers: { "X-Emby-Token": this.token },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as AxiosError).response?.status;
|
||||||
|
if (status === 401 && this.cfg.authMode === "userpass" && !opts.retried) {
|
||||||
|
this.token = "";
|
||||||
|
this.userId = "";
|
||||||
|
return this.request(method, path, { ...opts, retried: true });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MusicProvider ---
|
||||||
|
|
||||||
|
private coverFor(item: JellyfinItem): string {
|
||||||
|
return buildCoverUrl(this.cfg.serverUrl, this.token, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSongs(items: JellyfinItem[] | undefined | null): Song[] {
|
||||||
|
return (items ?? []).map((i) => mapJellyfinSong(i, this.coverFor(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
|
const common = {
|
||||||
|
searchTerm: query,
|
||||||
|
Recursive: true,
|
||||||
|
Limit: limit,
|
||||||
|
StartIndex: offset,
|
||||||
|
userId: this.userId,
|
||||||
|
};
|
||||||
|
const [songRes, albumRes, playlistRes] = await Promise.all([
|
||||||
|
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: { ...common, IncludeItemTypes: "Audio" },
|
||||||
|
}),
|
||||||
|
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: { ...common, IncludeItemTypes: "MusicAlbum", Fields: "ChildCount" },
|
||||||
|
}),
|
||||||
|
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: { ...common, IncludeItemTypes: "Playlist", Fields: "ChildCount" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
songs: this.mapSongs(songRes?.Items),
|
||||||
|
albums: (albumRes?.Items ?? []).map((i) => mapJellyfinAlbum(i, this.coverFor(i))),
|
||||||
|
playlists: (playlistRes?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||||
|
await this.ensureAuth();
|
||||||
|
const level = quality ?? this.quality;
|
||||||
|
const tier = JELLYFIN_QUALITY_LEVELS.find((l) => l.value === level);
|
||||||
|
if (!tier || tier.value === "direct") {
|
||||||
|
return { url: buildStreamUrl(this.cfg.serverUrl, this.token, songId) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: buildUniversalUrl(this.cfg.serverUrl, {
|
||||||
|
apiKey: this.token,
|
||||||
|
userId: this.userId,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
itemId: songId,
|
||||||
|
kbps: tier.bitrate,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: { ids: songId, userId: this.userId },
|
||||||
|
});
|
||||||
|
const item = res?.Items?.[0];
|
||||||
|
return item ? mapJellyfinSong(item, this.coverFor(item)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>(
|
||||||
|
"get",
|
||||||
|
`/Playlists/${playlistId}/Items`,
|
||||||
|
{ params: { userId: this.userId } },
|
||||||
|
);
|
||||||
|
return this.mapSongs(res?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
parentId: albumId,
|
||||||
|
sortBy: "ParentIndexNumber,IndexNumber",
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapSongs(res?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||||
|
return this.getUserPlaylists();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPlaylists(): Promise<Playlist[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Playlist",
|
||||||
|
Recursive: true,
|
||||||
|
Fields: "ChildCount",
|
||||||
|
SortBy: "SortName",
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (res?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: { ids: playlistId, Fields: "ChildCount,Overview", userId: this.userId },
|
||||||
|
});
|
||||||
|
const item = res?.Items?.[0];
|
||||||
|
if (!item) return null;
|
||||||
|
return {
|
||||||
|
id: String(item.Id),
|
||||||
|
name: item.Name ?? "",
|
||||||
|
description: item.Overview ?? "",
|
||||||
|
coverUrl: this.coverFor(item),
|
||||||
|
songCount: item.ChildCount ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.request<unknown>("get", `/Audio/${songId}/Lyrics`);
|
||||||
|
return mapJellyfinLyrics(res);
|
||||||
|
} catch (err) {
|
||||||
|
// 404 = the track simply has no lyrics — not an error.
|
||||||
|
if ((err as AxiosError).response?.status === 404) return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No QR-code login concept — connection is global and admin-configured.
|
||||||
|
async getQrCode(): Promise<QrCodeResult> {
|
||||||
|
return { qrUrl: "", key: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkQrCodeStatus(): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(cookie: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cookie) as PersistedAuth;
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
if (typeof parsed.accessToken === "string") this.token = parsed.accessToken;
|
||||||
|
if (typeof parsed.userId === "string") this.userId = parsed.userId;
|
||||||
|
if (typeof parsed.deviceId === "string") this.deviceId = parsed.deviceId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not the JSON blob we persist — ignore rather than corrupt auth state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookie(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
accessToken: this.token,
|
||||||
|
userId: this.userId,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
|
if (!this.cfg.serverUrl) return { loggedIn: false };
|
||||||
|
try {
|
||||||
|
const info = await this.request<{ ServerName?: string }>("get", "/System/Info");
|
||||||
|
const who = this.cfg.authMode === "apikey" ? "API Key" : this.cfg.username;
|
||||||
|
return { loggedIn: true, nickname: `${who}@${info?.ServerName ?? "Jellyfin"}` };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger?.debug({ err: describeJellyfinError(err) }, "Jellyfin auth status check failed");
|
||||||
|
return { loggedIn: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated round-trip to /System/Info for the Settings "test connection"
|
||||||
|
* button. `candidate` (when given) tests form values in a throwaway instance
|
||||||
|
* so a failed test never disturbs the live token state.
|
||||||
|
*/
|
||||||
|
async testConnection(
|
||||||
|
candidate?: JellyfinConfig,
|
||||||
|
): Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }> {
|
||||||
|
const probe = candidate ? new JellyfinProvider() : this;
|
||||||
|
if (candidate) probe.configure(candidate);
|
||||||
|
try {
|
||||||
|
const info = await probe.request<{ ServerName?: string; Version?: string }>(
|
||||||
|
"get",
|
||||||
|
"/System/Info",
|
||||||
|
);
|
||||||
|
return { ok: true, serverName: info?.ServerName, version: info?.Version };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: describeJellyfinError(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal FM = Instant Mix seeded from a random favorite, falling back to a
|
||||||
|
* random recently-played track, then a random library track. A final safety
|
||||||
|
* net returns 50 random tracks when Instant Mix itself yields nothing.
|
||||||
|
*/
|
||||||
|
async getPersonalFm(): Promise<Song[]> {
|
||||||
|
const seed = await this.pickFmSeed();
|
||||||
|
if (seed) {
|
||||||
|
const mix = await this.request<{ Items?: JellyfinItem[] }>(
|
||||||
|
"get",
|
||||||
|
`/Items/${seed}/InstantMix`,
|
||||||
|
{ params: { userId: this.userId, limit: 50 } },
|
||||||
|
);
|
||||||
|
const songs = this.mapSongs(mix?.Items);
|
||||||
|
if (songs.length > 0) return songs;
|
||||||
|
}
|
||||||
|
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "Random",
|
||||||
|
Limit: 50,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapSongs(random?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pickFmSeed(): Promise<string | null> {
|
||||||
|
const favorite = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
Filters: "IsFavorite",
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "Random",
|
||||||
|
Limit: 1,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (favorite?.Items?.[0]?.Id) return favorite.Items[0].Id;
|
||||||
|
|
||||||
|
const played = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
Filters: "IsPlayed",
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "DatePlayed",
|
||||||
|
SortOrder: "Descending",
|
||||||
|
Limit: 20,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const playedItems = played?.Items ?? [];
|
||||||
|
if (playedItems.length > 0) {
|
||||||
|
return playedItems[Math.floor(Math.random() * playedItems.length)].Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "Random",
|
||||||
|
Limit: 1,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return random?.Items?.[0]?.Id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Home sections ---
|
||||||
|
|
||||||
|
async getLatestAlbums(limit = 12): Promise<Album[]> {
|
||||||
|
await this.ensureAuth();
|
||||||
|
const items = await this.request<JellyfinItem[]>(
|
||||||
|
"get",
|
||||||
|
`/Users/${this.userId}/Items/Latest`,
|
||||||
|
{ params: { IncludeItemTypes: "MusicAlbum", Limit: limit, Fields: "ChildCount" } },
|
||||||
|
);
|
||||||
|
return (Array.isArray(items) ? items : []).map((i) =>
|
||||||
|
mapJellyfinAlbum(i, this.coverFor(i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMostPlayed(limit = 12): Promise<Song[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
SortBy: "PlayCount",
|
||||||
|
SortOrder: "Descending",
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
Filters: "IsPlayed",
|
||||||
|
Limit: limit,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapSongs(res?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavoriteSongs(limit = 100): Promise<Song[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
Filters: "IsFavorite",
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "SortName",
|
||||||
|
Limit: limit,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapSongs(res?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGenres(limit = 30): Promise<{ id: string; name: string }[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/MusicGenres", {
|
||||||
|
params: { userId: this.userId, Limit: limit, SortBy: "SortName" },
|
||||||
|
});
|
||||||
|
return (res?.Items ?? []).map((i) => ({ id: String(i.Id), name: i.Name ?? "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGenreSongs(genreId: string, limit = 100): Promise<Song[]> {
|
||||||
|
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||||
|
params: {
|
||||||
|
GenreIds: genreId,
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: "AlbumArtist,Album,SortName",
|
||||||
|
Limit: limit,
|
||||||
|
userId: this.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapSongs(res?.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playback reporting (Sessions API) ---
|
||||||
|
|
||||||
|
private async report(path: string, body: Record<string, unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.request("post", path, { data: body });
|
||||||
|
} catch (err) {
|
||||||
|
// Reporting must never affect playback — log and swallow.
|
||||||
|
this.logger?.debug(
|
||||||
|
{ err: describeJellyfinError(err), path },
|
||||||
|
"Jellyfin playback report failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlaybackReporter(): JellyfinPlaybackReporter {
|
||||||
|
// One session per track start; remember the last reported position so the
|
||||||
|
// implicit stop on track change carries a sane PositionTicks (a natural
|
||||||
|
// track end reports ~full duration → Jellyfin counts the play).
|
||||||
|
let current: { itemId: string; sessionId: string; lastPosSec: number } | null = null;
|
||||||
|
const report = this.report.bind(this);
|
||||||
|
return {
|
||||||
|
onTrackStart(itemId: string): void {
|
||||||
|
if (current) {
|
||||||
|
void report("/Sessions/Playing/Stopped", {
|
||||||
|
ItemId: current.itemId,
|
||||||
|
PlaySessionId: current.sessionId,
|
||||||
|
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = { itemId, sessionId: crypto.randomUUID(), lastPosSec: 0 };
|
||||||
|
void report("/Sessions/Playing", {
|
||||||
|
ItemId: itemId,
|
||||||
|
PlaySessionId: current.sessionId,
|
||||||
|
PositionTicks: 0,
|
||||||
|
CanSeek: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTick(itemId: string, positionSec: number, paused: boolean): void {
|
||||||
|
if (!current || current.itemId !== itemId) return;
|
||||||
|
current.lastPosSec = positionSec;
|
||||||
|
void report("/Sessions/Playing/Progress", {
|
||||||
|
ItemId: itemId,
|
||||||
|
PlaySessionId: current.sessionId,
|
||||||
|
PositionTicks: Math.round(positionSec * TICKS_PER_SECOND),
|
||||||
|
IsPaused: paused,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStop(): void {
|
||||||
|
if (!current) return;
|
||||||
|
void report("/Sessions/Playing/Stopped", {
|
||||||
|
ItemId: current.itemId,
|
||||||
|
PlaySessionId: current.sessionId,
|
||||||
|
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
|
current = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-2
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js";
|
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
describe("mapKugouSongs", () => {
|
describe("mapKugouSongs", () => {
|
||||||
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
|
|||||||
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
expect(mapKugouPlaylists(undefined)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("KugouProvider.search pagination", () => {
|
||||||
|
function mockProvider() {
|
||||||
|
const p = new KugouProvider();
|
||||||
|
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
|
||||||
|
(p as any).mobileHttp = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchParams(get: ReturnType<typeof vi.fn>) {
|
||||||
|
const call = get.mock.calls[0];
|
||||||
|
expect(call, "expected a mobile search call").toBeTruthy();
|
||||||
|
return call[1].params as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
const params = searchParams(get);
|
||||||
|
expect(params.page).toBe(2);
|
||||||
|
expect(params.pagesize).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20);
|
||||||
|
expect(searchParams(get).page).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-2
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
// --- Search (verified live via the unsigned mobile endpoint) ---------------
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return { songs: [], playlists: [], albums: [] };
|
if (!q) return { songs: [], playlists: [], albums: [] };
|
||||||
try {
|
try {
|
||||||
|
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
|
||||||
|
// steps so offset is a multiple of pagesize.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
|
||||||
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 },
|
params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
|
||||||
});
|
});
|
||||||
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
const info = res.data?.data?.info as KugouRawSong[] | undefined;
|
||||||
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
return { songs: mapKugouSongs(info), playlists: [], albums: [] };
|
||||||
|
|||||||
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LocalMusicProvider search pagination", () => {
|
||||||
|
it("slices [offset, offset+limit) instead of the first page", async () => {
|
||||||
|
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
|
||||||
|
seed(recs); // newest-first order preserved: a, b, c, d
|
||||||
|
const p = new LocalMusicProvider(dir);
|
||||||
|
|
||||||
|
const page1 = await p.search("", 2); // offset defaults to 0
|
||||||
|
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
|
||||||
|
|
||||||
|
const page2 = await p.search("", 2, 2);
|
||||||
|
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("LocalMusicProvider filename handling", () => {
|
describe("LocalMusicProvider filename handling", () => {
|
||||||
it("accepts a long filename without dropping its extension", async () => {
|
it("accepts a long filename without dropping its extension", async () => {
|
||||||
const p = new LocalMusicProvider(dir);
|
const p = new LocalMusicProvider(dir);
|
||||||
|
|||||||
+2
-2
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
|
|||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const songs = this.records
|
const songs = this.records
|
||||||
.filter((r) => existsSync(r.filePath))
|
.filter((r) => existsSync(r.filePath))
|
||||||
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
|
||||||
.slice(0, limit)
|
.slice(offset, offset + limit)
|
||||||
.map((r) => this.toSong(r));
|
.map((r) => this.toSong(r));
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
|
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
|
||||||
|
|
||||||
describe("NetEase adapter", () => {
|
describe("NetEase adapter", () => {
|
||||||
it("parses LRC format lyrics", () => {
|
it("parses LRC format lyrics", () => {
|
||||||
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
|
|||||||
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NeteaseProvider.search pagination", () => {
|
||||||
|
function mockProvider() {
|
||||||
|
const p = new NeteaseProvider("http://x");
|
||||||
|
const get = vi.fn().mockResolvedValue({
|
||||||
|
data: { result: { songs: [], playlists: [], albums: [] } },
|
||||||
|
});
|
||||||
|
(p as any).api = { get };
|
||||||
|
return { p, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the /cloudsearch call whose params.type matches. */
|
||||||
|
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
|
||||||
|
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
|
||||||
|
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
|
||||||
|
return call![1].params as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20, 20);
|
||||||
|
|
||||||
|
// songs (type 1): offset forwarded, limit unchanged
|
||||||
|
const songs = callByType(get, 1);
|
||||||
|
expect(songs.limit).toBe(20);
|
||||||
|
expect(songs.offset).toBe(20);
|
||||||
|
|
||||||
|
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
|
||||||
|
const playlists = callByType(get, 1000);
|
||||||
|
expect(playlists.limit).toBe(20);
|
||||||
|
expect(playlists.offset).toBe(20);
|
||||||
|
|
||||||
|
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
|
||||||
|
const albums = callByType(get, 10);
|
||||||
|
expect(albums.limit).toBe(20);
|
||||||
|
expect(albums.offset).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 (backward compatible)", async () => {
|
||||||
|
const { p, get } = mockProvider();
|
||||||
|
await p.search("hello", 20);
|
||||||
|
expect(callByType(get, 1).offset).toBe(0);
|
||||||
|
expect(callByType(get, 1000).offset).toBe(0);
|
||||||
|
expect(callByType(get, 10).offset).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
return this.cookie ? { cookie: this.cookie } : {};
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
|
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
|
||||||
|
// and albums (type 10) are all limit/offset-driven so the web can page past
|
||||||
|
// the first page (playlists/albums were previously hardcoded to limit: 10).
|
||||||
const [songRes, playlistRes, albumRes] = await Promise.all([
|
const [songRes, playlistRes, albumRes] = await Promise.all([
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: {
|
params: {
|
||||||
keywords: query,
|
keywords: query,
|
||||||
type: 1000,
|
type: 1000,
|
||||||
limit: 10,
|
limit,
|
||||||
|
offset,
|
||||||
...this.cookieParams,
|
...this.cookieParams,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -1,3 +1,15 @@
|
|||||||
|
/** All music source platforms. Jellyfin ItemIds are GUID strings — never
|
||||||
|
* assume numeric ids when handling a generic Platform. */
|
||||||
|
export type Platform =
|
||||||
|
| "netease"
|
||||||
|
| "qq"
|
||||||
|
| "bilibili"
|
||||||
|
| "youtube"
|
||||||
|
| "local"
|
||||||
|
| "kugou"
|
||||||
|
| "spotify"
|
||||||
|
| "jellyfin";
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,7 +17,7 @@ export interface Song {
|
|||||||
album: string;
|
album: string;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
platform: Platform;
|
||||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||||
vip?: boolean;
|
vip?: boolean;
|
||||||
@@ -27,7 +39,7 @@ export interface Playlist {
|
|||||||
name: string;
|
name: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
platform: Platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistDetail {
|
export interface PlaylistDetail {
|
||||||
@@ -44,7 +56,7 @@ export interface Album {
|
|||||||
artist: string;
|
artist: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
platform: Platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LyricLine {
|
export interface LyricLine {
|
||||||
@@ -72,9 +84,9 @@ export interface AuthStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MusicProvider {
|
export interface MusicProvider {
|
||||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
readonly platform: Platform;
|
||||||
|
|
||||||
search(query: string, limit?: number): Promise<SearchResult>;
|
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
|
||||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||||
setQuality(quality: string): void;
|
setQuality(quality: string): void;
|
||||||
getQuality(): string;
|
getQuality(): string;
|
||||||
|
|||||||
+82
-2
@@ -1,5 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
|
|
||||||
|
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
|
||||||
|
// and the per-instance api) share this single mock so the search test can
|
||||||
|
// inspect the outgoing params/body regardless of which client issued them.
|
||||||
|
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: { create: () => ({ get: mockGet, post: mockPost }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
|
|||||||
expect(out[0].id).toBe("");
|
expect(out[0].id).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("QQMusicProvider.search pagination", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** musicu.fcg returns one song → primary path succeeds. */
|
||||||
|
function musicuOk() {
|
||||||
|
mockGet.mockImplementation(async (url: string) => {
|
||||||
|
if (url === "/cgi-bin/musicu.fcg") {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
|
||||||
|
req_album: { data: { body: { album: { list: [] } } } },
|
||||||
|
req_playlist: { data: { body: { songlist: { list: [] } } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function musicuReqData() {
|
||||||
|
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
|
||||||
|
expect(call, "expected a musicu.fcg call").toBeTruthy();
|
||||||
|
return JSON.parse(call![1].params.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
|
||||||
|
musicuOk();
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
|
||||||
|
const d = musicuReqData();
|
||||||
|
expect(d.req_0.param.page_num).toBe(2);
|
||||||
|
expect(d.req_0.param.num_per_page).toBe(20);
|
||||||
|
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
|
||||||
|
expect(d.req_album.param.page_num).toBe(2);
|
||||||
|
expect(d.req_album.param.num_per_page).toBe(20);
|
||||||
|
expect(d.req_playlist.param.page_num).toBe(2);
|
||||||
|
expect(d.req_playlist.param.num_per_page).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
|
||||||
|
musicuOk();
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20);
|
||||||
|
const d = musicuReqData();
|
||||||
|
expect(d.req_0.param.page_num).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fallback client_search_cp sets p to the page cursor", async () => {
|
||||||
|
// musicu returns no songs → primary returns null → fallback runs.
|
||||||
|
mockGet.mockImplementation(async (url: string) => {
|
||||||
|
if (url === "/cgi-bin/musicu.fcg") {
|
||||||
|
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
|
||||||
|
}
|
||||||
|
// client_search_cp
|
||||||
|
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
|
||||||
|
});
|
||||||
|
const p = new QQMusicProvider("http://x");
|
||||||
|
await p.search("hello", 20, 20); // page 2
|
||||||
|
|
||||||
|
const songCall = mockGet.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
|
||||||
|
);
|
||||||
|
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
|
||||||
|
expect(songCall![1].params.p).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+67
-11
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||||
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
||||||
const primary = await this.searchViaMusicuFcg(query, limit);
|
const primary = await this.searchViaMusicuFcg(query, limit, offset);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
|
|
||||||
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||||
// no playlist support). Kept as redundancy.
|
// no playlist support). Kept as redundancy.
|
||||||
return this.searchViaClientSearchCp(query, limit);
|
return this.searchViaClientSearchCp(query, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
||||||
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
||||||
private async searchViaMusicuFcg(
|
private async searchViaMusicuFcg(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
offset = 0
|
||||||
): Promise<SearchResult | null> {
|
): Promise<SearchResult | null> {
|
||||||
try {
|
try {
|
||||||
|
// num_per_page must stay >= 10 (lower values return empty). It is now
|
||||||
|
// limit-driven for ALL three lists (albums/playlists were hardcoded to
|
||||||
|
// 10). page_num is the offset cursor; the web always requests in
|
||||||
|
// limit-aligned pages so offset is a multiple of limit.
|
||||||
const numPerPage = Math.max(10, Math.min(limit, 50));
|
const numPerPage = Math.max(10, Math.min(limit, 50));
|
||||||
|
const pageNum = Math.floor(offset / limit) + 1;
|
||||||
const reqData = JSON.stringify({
|
const reqData = JSON.stringify({
|
||||||
req_0: {
|
req_0: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: numPerPage, search_type: 0 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
|
||||||
},
|
},
|
||||||
req_album: {
|
req_album: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: 10, search_type: 2 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
|
||||||
},
|
},
|
||||||
req_playlist: {
|
req_playlist: {
|
||||||
module: "music.search.SearchCgiService",
|
module: "music.search.SearchCgiService",
|
||||||
method: "DoSearchForQQMusicDesktop",
|
method: "DoSearchForQQMusicDesktop",
|
||||||
param: { query, num_per_page: 10, search_type: 3 },
|
param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
||||||
private async searchViaClientSearchCp(
|
private async searchViaClientSearchCp(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
offset = 0
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
|
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
|
||||||
|
// offset is a multiple of limit.
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
const songParams = {
|
const songParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: 1,
|
p: page,
|
||||||
n: Math.min(limit, 50),
|
n: Math.min(limit, 50),
|
||||||
type: 0,
|
type: 0,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const albumParams = {
|
const albumParams = {
|
||||||
w: query,
|
w: query,
|
||||||
format: "json",
|
format: "json",
|
||||||
p: 1,
|
p: page,
|
||||||
n: 5,
|
n: 5,
|
||||||
t: 8,
|
t: 8,
|
||||||
cr: 1,
|
cr: 1,
|
||||||
@@ -354,8 +364,18 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to stub
|
// fall through to musicu.fcg fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: fetch metadata via u.y.qq.com/cgi-bin/musicu.fcg. The bundled
|
||||||
|
// library's /getSongInfo route is broken (upstream code 500001), so the
|
||||||
|
// primary try above almost always falls through here for QQ. Without this,
|
||||||
|
// play-by-id songs carry an empty name → the TS nickname / now-playing
|
||||||
|
// message renders as "♪ 正在播放: - []". This endpoint (same host/shape
|
||||||
|
// as search) reliably returns full track_info without requiring login.
|
||||||
|
const detail = await this.fetchSongDetailViaMusicu(songId);
|
||||||
|
if (detail) return detail;
|
||||||
|
|
||||||
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
|
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
|
||||||
// play URL. Name/artist/album will be empty in play history, but the
|
// play URL. Name/artist/album will be empty in play history, but the
|
||||||
// song will actually play, which is the important part.
|
// song will actually play, which is the important part.
|
||||||
@@ -370,6 +390,42 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a single song's metadata via u.y.qq.com/cgi-bin/musicu.fcg
|
||||||
|
* (module music.pf_song_detail_svr / get_song_detail_yqq). This mirrors the
|
||||||
|
* search path's host + request shape and, unlike the library's /getSongInfo,
|
||||||
|
* actually works against QQ's current API. Returns null on any failure so the
|
||||||
|
* caller can fall back to the minimal stub. */
|
||||||
|
private async fetchSongDetailViaMusicu(songId: string): Promise<Song | null> {
|
||||||
|
try {
|
||||||
|
const reqData = JSON.stringify({
|
||||||
|
req_0: {
|
||||||
|
module: "music.pf_song_detail_svr",
|
||||||
|
method: "get_song_detail_yqq",
|
||||||
|
param: { song_mid: songId, song_type: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||||
|
params: { format: "json", data: reqData },
|
||||||
|
});
|
||||||
|
const t = res.data?.req_0?.data?.track_info;
|
||||||
|
if (!t) return null;
|
||||||
|
const albumMid = t.album?.mid ?? t.album?.pmid ?? "";
|
||||||
|
return {
|
||||||
|
id: String(t.mid ?? t.id ?? songId),
|
||||||
|
name: t.name ?? t.title ?? "",
|
||||||
|
artist: (t.singer ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
||||||
|
album: t.album?.name ?? t.album?.title ?? "",
|
||||||
|
duration: t.interval ?? 0,
|
||||||
|
coverUrl: albumMid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||||
const res = await this.api.get("/getSongListDetail", {
|
const res = await this.api.get("/getSongListDetail", {
|
||||||
params: { disstid: playlistId, ...this.cookieParams },
|
params: { disstid: playlistId, ...this.cookieParams },
|
||||||
|
|||||||
+12
-6
@@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider {
|
|||||||
readonly platform = "youtube" as const;
|
readonly platform = "youtube" as const;
|
||||||
private quality = "bestaudio";
|
private quality = "bestaudio";
|
||||||
|
|
||||||
async search(query: string, limit = 5): Promise<SearchResult> {
|
async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
|
||||||
try {
|
try {
|
||||||
|
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
|
||||||
|
// N results. Best-effort paginate by fetching offset+limit and slicing
|
||||||
|
// locally. (offset 0 → identical to before.)
|
||||||
|
const total = offset + limit;
|
||||||
const raw = await runYtDlp([
|
const raw = await runYtDlp([
|
||||||
`ytsearch${limit}:${query}`,
|
`ytsearch${total}:${query}`,
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
]);
|
]);
|
||||||
const lines = raw.trim().split("\n").filter(Boolean);
|
const lines = raw.trim().split("\n").filter(Boolean);
|
||||||
const songs: Song[] = lines.map((line) => {
|
const songs: Song[] = lines
|
||||||
const entry = JSON.parse(line) as YtDlpEntry;
|
.slice(offset, offset + limit)
|
||||||
return entryToSong(entry);
|
.map((line) => {
|
||||||
});
|
const entry = JSON.parse(line) as YtDlpEntry;
|
||||||
|
return entryToSong(entry);
|
||||||
|
});
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
} catch {
|
} catch {
|
||||||
return { songs: [], playlists: [], albums: [] };
|
return { songs: [], playlists: [], albums: [] };
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
|
import { getDefaultConfig, type JellyfinConfig } from "../../data/config.js";
|
||||||
|
import { createAuthRouter } from "./auth.js";
|
||||||
|
|
||||||
|
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
|
||||||
|
return { platform } as unknown as MusicProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("auth router POST /jellyfin/test", () => {
|
||||||
|
function mount(
|
||||||
|
stored: Partial<JellyfinConfig>,
|
||||||
|
user: unknown = { role: "admin" },
|
||||||
|
) {
|
||||||
|
const config = getDefaultConfig();
|
||||||
|
Object.assign(config.jellyfin, stored);
|
||||||
|
const testConnection = vi.fn().mockResolvedValue({ ok: true, serverName: "JF" });
|
||||||
|
const jellyfin = { platform: "jellyfin", testConnection } as unknown as MusicProvider;
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { (req as { user?: unknown }).user = user; next(); });
|
||||||
|
app.use(
|
||||||
|
"/api/auth",
|
||||||
|
createAuthRouter(
|
||||||
|
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||||
|
pino({ level: "silent" }), undefined, undefined, undefined, jellyfin, config,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { app, testConnection };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("fills empty credential fields from the stored config (masked password case)", async () => {
|
||||||
|
const { app, testConnection } = mount({
|
||||||
|
serverUrl: "https://old.example.com",
|
||||||
|
username: "bob",
|
||||||
|
password: "stored-pw",
|
||||||
|
});
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/jellyfin/test")
|
||||||
|
.send({ serverUrl: "https://new.example.com", username: "bob", password: "" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(testConnection).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
serverUrl: "https://new.example.com",
|
||||||
|
username: "bob",
|
||||||
|
password: "stored-pw",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes freshly entered credentials through", async () => {
|
||||||
|
const { app, testConnection } = mount({});
|
||||||
|
await request(app).post("/api/auth/jellyfin/test").send({
|
||||||
|
serverUrl: "https://jf.example.com",
|
||||||
|
authMode: "apikey",
|
||||||
|
apiKey: "key123",
|
||||||
|
userId: "u1",
|
||||||
|
});
|
||||||
|
expect(testConnection).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ authMode: "apikey", apiKey: "key123", userId: "u1" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is 403 for a member lacking platform.auth", async () => {
|
||||||
|
const { app, testConnection } = mount({}, { role: "member", capabilities: new Set([]) });
|
||||||
|
const res = await request(app).post("/api/auth/jellyfin/test").send({});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(testConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+52
-1
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
|
|||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
import { YouTubeProvider } from "../../music/youtube.js";
|
||||||
import type { CookieStore } from "../../music/auth.js";
|
import type { CookieStore } from "../../music/auth.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
import type { BotConfig, JellyfinConfig } from "../../data/config.js";
|
||||||
import { requirePermission } from "../middleware/requirePermission.js";
|
import { requirePermission } from "../middleware/requirePermission.js";
|
||||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||||
|
|
||||||
@@ -13,7 +14,9 @@ export function createAuthRouter(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
cookieStore?: CookieStore,
|
cookieStore?: CookieStore,
|
||||||
kugouProvider?: MusicProvider,
|
kugouProvider?: MusicProvider,
|
||||||
spotifyProvider?: MusicProvider
|
spotifyProvider?: MusicProvider,
|
||||||
|
jellyfinProvider?: MusicProvider,
|
||||||
|
config?: BotConfig
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
// YouTube is auth-less; we only use this instance so /auth/status can
|
// YouTube is auth-less; we only use this instance so /auth/status can
|
||||||
@@ -25,6 +28,7 @@ export function createAuthRouter(
|
|||||||
if (platform === "youtube") return youtubeProvider;
|
if (platform === "youtube") return youtubeProvider;
|
||||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||||
|
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
|
||||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,45 @@ export function createAuthRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Jellyfin has no QR/cookie flow — the connection is admin-configured. This
|
||||||
|
// round-trips /System/Info so Settings can verify form values BEFORE saving.
|
||||||
|
// Empty/missing credential fields fall back to the stored config, so a
|
||||||
|
// masked (not re-entered) password still tests the live setup.
|
||||||
|
router.post("/jellyfin/test", requirePermission("platform.auth"), async (req, res) => {
|
||||||
|
const testable = jellyfinProvider as
|
||||||
|
| (MusicProvider & {
|
||||||
|
testConnection?: (
|
||||||
|
candidate?: JellyfinConfig,
|
||||||
|
) => Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }>;
|
||||||
|
})
|
||||||
|
| undefined;
|
||||||
|
if (!testable?.testConnection) {
|
||||||
|
res.status(501).json({ error: "Jellyfin provider not available" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = (req.body ?? {}) as Partial<JellyfinConfig>;
|
||||||
|
const stored = config?.jellyfin;
|
||||||
|
const str = (v: unknown, fallback: string) =>
|
||||||
|
typeof v === "string" && v.trim() !== "" ? v.trim() : fallback;
|
||||||
|
const candidate: JellyfinConfig = {
|
||||||
|
serverUrl: str(body.serverUrl, stored?.serverUrl ?? ""),
|
||||||
|
authMode:
|
||||||
|
body.authMode === "apikey" || body.authMode === "userpass"
|
||||||
|
? body.authMode
|
||||||
|
: stored?.authMode ?? "userpass",
|
||||||
|
username: str(body.username, stored?.username ?? ""),
|
||||||
|
password: str(body.password, stored?.password ?? ""),
|
||||||
|
apiKey: str(body.apiKey, stored?.apiKey ?? ""),
|
||||||
|
userId: str(body.userId, stored?.userId ?? ""),
|
||||||
|
};
|
||||||
|
res.json(await testable.testConnection(candidate));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin test connection failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
|
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { phone } = req.body;
|
const { phone } = req.body;
|
||||||
@@ -139,6 +182,14 @@ export function createAuthRouter(
|
|||||||
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
|
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Jellyfin auth is server-configured (Settings → connection card), not
|
||||||
|
// cookie-based; falling through would clobber the NetEase cookie entry.
|
||||||
|
if (platform === "jellyfin") {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Jellyfin 通过 Settings 配置连接,不支持手动 Cookie" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const provider = getProvider(platform);
|
const provider = getProvider(platform);
|
||||||
provider.setCookie(cookie);
|
provider.setCookie(cookie);
|
||||||
const plat = platform === "bilibili" ? "bilibili" as const
|
const plat = platform === "bilibili" ? "bilibili" as const
|
||||||
|
|||||||
+88
-1
@@ -13,7 +13,7 @@ import { createAvatarStore } from "../../data/avatars.js";
|
|||||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
import { createPermissionStore } from "../../data/permissions.js";
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
import { createBotRouter } from "./bot.js";
|
import { createBotRouter } from "./bot.js";
|
||||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
|
||||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
|
||||||
@@ -561,3 +561,90 @@ describe("bot router /settings guest-mode gating + persistence", () => {
|
|||||||
expect(config.spotify.clientId).toBe("");
|
expect(config.spotify.clientId).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bot router /settings jellyfin block + enabledProviders", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let configPath: string;
|
||||||
|
let config: BotConfig;
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let configureCalls: JellyfinConfig[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-jf-"));
|
||||||
|
configPath = join(tmpDir, "config.json");
|
||||||
|
config = getDefaultConfig();
|
||||||
|
configureCalls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountBot(): express.Express {
|
||||||
|
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
|
||||||
|
const avatarStore = createAvatarStore(tmpDir);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { (req as { user?: unknown }).user = { role: "admin" }; next(); });
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(
|
||||||
|
fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore,
|
||||||
|
undefined, undefined, undefined,
|
||||||
|
{ configure: (cfg: JellyfinConfig) => configureCalls.push({ ...cfg }) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("GET /settings exposes a masked jellyfin block (no password/apiKey echo)", async () => {
|
||||||
|
config.jellyfin.serverUrl = "https://jf.example.com";
|
||||||
|
config.jellyfin.password = "secret";
|
||||||
|
const res = await request(mountBot()).get("/api/bot/settings");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.jellyfin).toEqual({
|
||||||
|
serverUrl: "https://jf.example.com",
|
||||||
|
authMode: "userpass",
|
||||||
|
username: "",
|
||||||
|
userId: "",
|
||||||
|
hasPassword: true,
|
||||||
|
hasApiKey: false,
|
||||||
|
});
|
||||||
|
// Default: online sources on, jellyfin opt-in (not listed).
|
||||||
|
expect(res.body.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
|
||||||
|
config.jellyfin.password = "stored-pw";
|
||||||
|
const app = mountBot();
|
||||||
|
const res = await request(app).post("/api/bot/settings").send({
|
||||||
|
jellyfin: { serverUrl: "https://jf.example.com///", username: "bob", password: "" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Trailing slashes normalized; blank password kept the stored one.
|
||||||
|
expect(config.jellyfin.serverUrl).toBe("https://jf.example.com");
|
||||||
|
expect(config.jellyfin.username).toBe("bob");
|
||||||
|
expect(config.jellyfin.password).toBe("stored-pw");
|
||||||
|
expect(res.body.jellyfin.hasPassword).toBe(true);
|
||||||
|
// Live provider re-configured with the post-merge values.
|
||||||
|
expect(configureCalls).toHaveLength(1);
|
||||||
|
expect(configureCalls[0].serverUrl).toBe("https://jf.example.com");
|
||||||
|
expect(configureCalls[0].password).toBe("stored-pw");
|
||||||
|
// Persisted to disk.
|
||||||
|
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||||
|
expect(onDisk.jellyfin.username).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings filters enabledProviders to known providers", async () => {
|
||||||
|
const res = await request(mountBot()).post("/api/bot/settings").send({
|
||||||
|
enabledProviders: ["jellyfin", "netease", "bogus", 42],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.enabledProviders).toEqual(["jellyfin", "netease"]);
|
||||||
|
expect(config.enabledProviders).toEqual(["jellyfin", "netease"]);
|
||||||
|
// No jellyfin block in the request → no reconfigure call.
|
||||||
|
expect(configureCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+55
-2
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js";
|
import type { BotConfig, GuestModeConfig, SpotifyConfig, JellyfinConfig, GateableProvider } from "../../data/config.js";
|
||||||
import { saveConfig } from "../../data/config.js";
|
import { saveConfig, GATEABLE_PROVIDERS } from "../../data/config.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import type { BotDatabase } from "../../data/database.js";
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
import type { AvatarStore } from "../../data/avatars.js";
|
import type { AvatarStore } from "../../data/avatars.js";
|
||||||
@@ -25,6 +25,11 @@ export function createBotRouter(
|
|||||||
// Settings must push creds here too, or search stays empty until a restart.
|
// Settings must push creds here too, or search stays empty until a restart.
|
||||||
// Structural type = SpotifyProvider.setCreds.
|
// Structural type = SpotifyProvider.setCreds.
|
||||||
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
|
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
|
||||||
|
// The live JellyfinProvider, so a Settings save re-points the connection
|
||||||
|
// without a restart (configure() drops the cached token only when
|
||||||
|
// credential-relevant fields actually changed). Structural type =
|
||||||
|
// JellyfinProvider.configure.
|
||||||
|
jellyfinProvider?: { configure(cfg: JellyfinConfig): void },
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -39,6 +44,17 @@ export function createBotRouter(
|
|||||||
hasClientSecret: config.spotify.clientSecret.length > 0,
|
hasClientSecret: config.spotify.clientSecret.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Masked jellyfin view: password and apiKey are write-only — only their
|
||||||
|
// presence is reported (mirrors maskedSpotify's clientSecret handling).
|
||||||
|
const maskedJellyfin = () => ({
|
||||||
|
serverUrl: config.jellyfin.serverUrl,
|
||||||
|
authMode: config.jellyfin.authMode,
|
||||||
|
username: config.jellyfin.username,
|
||||||
|
userId: config.jellyfin.userId,
|
||||||
|
hasPassword: config.jellyfin.password.length > 0,
|
||||||
|
hasApiKey: config.jellyfin.apiKey.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/", (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const all = botManager.getAllBots().map((b) => b.getStatus());
|
const all = botManager.getAllBots().map((b) => b.getStatus());
|
||||||
const u = req.user!;
|
const u = req.user!;
|
||||||
@@ -59,6 +75,8 @@ export function createBotRouter(
|
|||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
spotify: maskedSpotify(),
|
spotify: maskedSpotify(),
|
||||||
|
jellyfin: maskedJellyfin(),
|
||||||
|
enabledProviders: config.enabledProviders,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,8 +148,41 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partial-merge the jellyfin block (same contract as spotify): invalid
|
||||||
|
// sub-fields are ignored; password/apiKey are write-only and only stored
|
||||||
|
// when non-empty so a blank (masked) field never wipes them.
|
||||||
|
const jf = req.body?.jellyfin;
|
||||||
|
if (jf && typeof jf === "object") {
|
||||||
|
const t = config.jellyfin;
|
||||||
|
if (typeof jf.serverUrl === "string") {
|
||||||
|
t.serverUrl = jf.serverUrl.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
if (jf.authMode === "userpass" || jf.authMode === "apikey") t.authMode = jf.authMode;
|
||||||
|
if (typeof jf.username === "string") t.username = jf.username;
|
||||||
|
if (typeof jf.password === "string" && jf.password.length > 0) t.password = jf.password;
|
||||||
|
if (typeof jf.apiKey === "string" && jf.apiKey.length > 0) t.apiKey = jf.apiKey;
|
||||||
|
if (typeof jf.userId === "string") t.userId = jf.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabledProviders: full replace, known providers only (mirrors loadConfig).
|
||||||
|
// An empty array is a valid "all gateable sources off". NOTE: the NetEase/QQ
|
||||||
|
// sidecar API servers are only started at boot, so newly enabling those two
|
||||||
|
// still needs a restart; jellyfin and the rest take effect immediately.
|
||||||
|
const ep = req.body?.enabledProviders;
|
||||||
|
if (Array.isArray(ep)) {
|
||||||
|
config.enabledProviders = ep.filter((p: unknown): p is GateableProvider =>
|
||||||
|
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
saveConfig(configPath, config);
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
|
||||||
|
// provider — a Settings save must work without a restart.
|
||||||
|
if (jf && typeof jf === "object") {
|
||||||
|
jellyfinProvider?.configure(config.jellyfin);
|
||||||
|
}
|
||||||
|
|
||||||
// I2: only when the spotify block was present, push the (possibly UI-entered)
|
// I2: only when the spotify block was present, push the (possibly UI-entered)
|
||||||
// Client ID into the live process-wide OAuth so Connect works without a
|
// Client ID into the live process-wide OAuth so Connect works without a
|
||||||
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
|
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
|
||||||
@@ -166,6 +217,8 @@ export function createBotRouter(
|
|||||||
adminGroups: config.adminGroups ?? [],
|
adminGroups: config.adminGroups ?? [],
|
||||||
guestMode: config.guestMode,
|
guestMode: config.guestMode,
|
||||||
spotify: maskedSpotify(),
|
spotify: maskedSpotify(),
|
||||||
|
jellyfin: maskedJellyfin(),
|
||||||
|
enabledProviders: config.enabledProviders,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import type { MusicProvider, SearchResult } from "../../music/provider.js";
|
||||||
|
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||||
|
import { createMusicRouter } from "./music.js";
|
||||||
|
|
||||||
|
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||||
|
|
||||||
|
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
search: vi.fn().mockResolvedValue(empty),
|
||||||
|
} as unknown as MusicProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("music router GET /search offset pagination", () => {
|
||||||
|
let app: express.Express;
|
||||||
|
let netease: MusicProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
netease = fakeProvider("netease");
|
||||||
|
const router = createMusicRouter(
|
||||||
|
netease,
|
||||||
|
fakeProvider("qq"),
|
||||||
|
fakeProvider("bilibili"),
|
||||||
|
pino({ level: "silent" })
|
||||||
|
);
|
||||||
|
app = express();
|
||||||
|
app.use("/api/music", router);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a missing offset to 0", async () => {
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello&limit=20");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps a negative offset to 0", async () => {
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("music router provider gating (enabledProviders) + jellyfin endpoints", () => {
|
||||||
|
function jellyfinFake(): MusicProvider {
|
||||||
|
return {
|
||||||
|
platform: "jellyfin",
|
||||||
|
search: vi.fn().mockResolvedValue(empty),
|
||||||
|
getQuality: vi.fn().mockReturnValue("direct"),
|
||||||
|
getLatestAlbums: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: "a1", name: "Album", platform: "jellyfin" }]),
|
||||||
|
getFavoriteSongs: vi.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as MusicProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(config: BotConfig) {
|
||||||
|
const netease = fakeProvider("netease");
|
||||||
|
const jellyfin = jellyfinFake();
|
||||||
|
const router = createMusicRouter(
|
||||||
|
netease,
|
||||||
|
fakeProvider("qq"),
|
||||||
|
fakeProvider("bilibili"),
|
||||||
|
pino({ level: "silent" }),
|
||||||
|
undefined,
|
||||||
|
config,
|
||||||
|
fakeProvider("kugou"),
|
||||||
|
fakeProvider("spotify"),
|
||||||
|
jellyfin,
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use("/api/music", router);
|
||||||
|
return { app, netease, jellyfin };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("routes a platform-less /search to the default platform (netease)", async () => {
|
||||||
|
const { app, netease, jellyfin } = mount(getDefaultConfig());
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||||
|
expect(jellyfin.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a disabled platform with 400 without calling its provider", async () => {
|
||||||
|
// Default config leaves jellyfin (opt-in) disabled.
|
||||||
|
const { app, jellyfin } = mount(getDefaultConfig());
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(jellyfin.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an explicitly enabled jellyfin platform", async () => {
|
||||||
|
const config = getDefaultConfig();
|
||||||
|
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
|
||||||
|
const { app, jellyfin } = mount(config);
|
||||||
|
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /providers reports enabled sources and the default platform", async () => {
|
||||||
|
const { app } = mount(getDefaultConfig());
|
||||||
|
const res = await request(app).get("/api/music/providers");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.default).toBe("netease");
|
||||||
|
expect(res.body.enabled).toContain("netease");
|
||||||
|
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
|
||||||
|
expect(res.body.enabled).not.toContain("jellyfin"); // opt-in, off by default
|
||||||
|
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Default config plus the opt-in jellyfin source enabled. */
|
||||||
|
function configWithJellyfin() {
|
||||||
|
const config = getDefaultConfig();
|
||||||
|
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("GET /jellyfin/latest-albums returns provider data", async () => {
|
||||||
|
const { app, jellyfin } = mount(configWithJellyfin());
|
||||||
|
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(jellyfin as unknown as { getLatestAlbums: ReturnType<typeof vi.fn> }).getLatestAlbums,
|
||||||
|
).toHaveBeenCalledWith(5);
|
||||||
|
expect(res.body.albums).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
|
||||||
|
const { app } = mount(getDefaultConfig());
|
||||||
|
const res = await request(app).get("/api/music/jellyfin/latest-albums");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => {
|
||||||
|
const { app } = mount(configWithJellyfin());
|
||||||
|
const res = await request(app).get("/api/music/jellyfin/favorites");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
+178
-22
@@ -1,8 +1,8 @@
|
|||||||
import express, { Router } from "express";
|
import express, { Router, type Response } from "express";
|
||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider, Song, Album } from "../../music/provider.js";
|
||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
import { YouTubeProvider } from "../../music/youtube.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import type { BotConfig } from "../../data/config.js";
|
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
|
||||||
import { requirePermission } from "../middleware/requirePermission.js";
|
import { requirePermission } from "../middleware/requirePermission.js";
|
||||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||||
import { authorize } from "../middleware/authorize.js";
|
import { authorize } from "../middleware/authorize.js";
|
||||||
@@ -15,7 +15,8 @@ export function createMusicRouter(
|
|||||||
localProvider?: MusicProvider,
|
localProvider?: MusicProvider,
|
||||||
config?: BotConfig,
|
config?: BotConfig,
|
||||||
kugouProvider?: MusicProvider,
|
kugouProvider?: MusicProvider,
|
||||||
spotifyProvider?: MusicProvider
|
spotifyProvider?: MusicProvider,
|
||||||
|
jellyfinProvider?: MusicProvider
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||||
@@ -30,9 +31,27 @@ export function createMusicRouter(
|
|||||||
if (platform === "local" && localProvider) return localProvider;
|
if (platform === "local" && localProvider) return localProvider;
|
||||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||||
|
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
|
||||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider gating for user-supplied platform params. No platform → the
|
||||||
|
* configured default (see defaultPlatform(); netease in the default config).
|
||||||
|
* A disabled platform gets a friendly 400 and null back — the handler must
|
||||||
|
* return immediately. Without a config (unit-test routers), everything
|
||||||
|
* stays enabled.
|
||||||
|
*/
|
||||||
|
function resolveProvider(platform: unknown, res: Response): MusicProvider | null {
|
||||||
|
const requested = typeof platform === "string" && platform ? platform : undefined;
|
||||||
|
const target = requested ?? (config ? defaultPlatform(config) : "netease");
|
||||||
|
if (config && !isProviderEnabled(config, target)) {
|
||||||
|
res.status(400).json({ error: `音源未启用:${target} (provider disabled)` });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getProvider(target);
|
||||||
|
}
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/local/upload",
|
"/local/upload",
|
||||||
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
|
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
|
||||||
@@ -86,7 +105,7 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/search", async (req, res) => {
|
router.get("/search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, platform, limit } = req.query;
|
const { q, platform, limit, offset } = req.query;
|
||||||
if (!q) {
|
if (!q) {
|
||||||
res.status(400).json({ error: "q (query) is required" });
|
res.status(400).json({ error: "q (query) is required" });
|
||||||
return;
|
return;
|
||||||
@@ -95,10 +114,15 @@ export function createMusicRouter(
|
|||||||
res.json({ songs: [], playlists: [], albums: [] });
|
res.json({ songs: [], playlists: [], albums: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = getProvider(platform as string);
|
const provider = resolveProvider(platform, res);
|
||||||
|
if (!provider) return;
|
||||||
|
// Server-side pagination: offset lets the web load past the first page.
|
||||||
|
// Clamp to >= 0 so a bad/negative value falls back to the first page.
|
||||||
|
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
|
||||||
const result = await provider.search(
|
const result = await provider.search(
|
||||||
q as string,
|
q as string,
|
||||||
parseInt(limit as string) || 20
|
parseInt(limit as string) || 20,
|
||||||
|
parsedOffset
|
||||||
);
|
);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -120,15 +144,22 @@ export function createMusicRouter(
|
|||||||
// backend lands (Stage 2/3), so surfacing them in the default all-sources
|
// backend lands (Stage 2/3), so surfacing them in the default all-sources
|
||||||
// view would only yield results that get skipped. Spotify search remains
|
// view would only yield results that get skipped. Spotify search remains
|
||||||
// available from its own tab via /search?platform=spotify.
|
// available from its own tab via /search?platform=spotify.
|
||||||
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
// Provider gating (#enabledProviders): disabled sources are skipped, not
|
||||||
neteaseProvider.search(q as string, parsedLimit),
|
// searched. Jellyfin (an opt-in source) leads the merged results when
|
||||||
qqProvider.search(q as string, parsedLimit),
|
// enabled — a self-hosted library match is almost always the wanted one.
|
||||||
bilibiliProvider.search(q as string, parsedLimit),
|
const enabled = (p: string) => !config || isProviderEnabled(config, p);
|
||||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
const none = { songs: [], albums: [], playlists: [] };
|
||||||
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||||
|
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
|
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
|
enabled("qq") ? qqProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
|
enabled("bilibili") ? bilibiliProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
|
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
|
kugouProvider && enabled("kugou") ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const songs = [
|
const songs = [
|
||||||
|
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.songs : []),
|
||||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||||
@@ -136,10 +167,12 @@ export function createMusicRouter(
|
|||||||
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
||||||
];
|
];
|
||||||
const albums = [
|
const albums = [
|
||||||
|
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.albums : []),
|
||||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||||
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
|
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
|
||||||
];
|
];
|
||||||
const playlists = [
|
const playlists = [
|
||||||
|
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.playlists : []),
|
||||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||||
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
||||||
];
|
];
|
||||||
@@ -157,7 +190,8 @@ export function createMusicRouter(
|
|||||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
const song = await provider.getSongDetail(req.params.id);
|
const song = await provider.getSongDetail(req.params.id);
|
||||||
if (!song) {
|
if (!song) {
|
||||||
res.status(404).json({ error: "Song not found" });
|
res.status(404).json({ error: "Song not found" });
|
||||||
@@ -171,7 +205,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/playlist/:id", async (req, res) => {
|
router.get("/playlist/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
const songs = await provider.getPlaylistSongs(req.params.id);
|
const songs = await provider.getPlaylistSongs(req.params.id);
|
||||||
res.json({ songs });
|
res.json({ songs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -181,7 +216,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/recommend/playlists", async (req, res) => {
|
router.get("/recommend/playlists", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
const playlists = await provider.getRecommendPlaylists();
|
const playlists = await provider.getRecommendPlaylists();
|
||||||
res.json({ playlists });
|
res.json({ playlists });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -191,7 +227,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/album/:id", async (req, res) => {
|
router.get("/album/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
const songs = await provider.getAlbumSongs(req.params.id);
|
const songs = await provider.getAlbumSongs(req.params.id);
|
||||||
res.json({ songs });
|
res.json({ songs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -201,7 +238,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/lyrics/:id", async (req, res) => {
|
router.get("/lyrics/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
const lyrics = await provider.getLyrics(req.params.id);
|
const lyrics = await provider.getLyrics(req.params.id);
|
||||||
res.json({ lyrics });
|
res.json({ lyrics });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -211,7 +249,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
if (!provider.getDailyRecommendSongs) {
|
if (!provider.getDailyRecommendSongs) {
|
||||||
res.status(501).json({ error: "Not supported by this provider" });
|
res.status(501).json({ error: "Not supported by this provider" });
|
||||||
return;
|
return;
|
||||||
@@ -226,7 +265,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/personal/fm", requireNotGuest, async (req, res) => {
|
router.get("/personal/fm", requireNotGuest, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
if (!provider.getPersonalFm) {
|
if (!provider.getPersonalFm) {
|
||||||
res.status(501).json({ error: "Not supported by this provider" });
|
res.status(501).json({ error: "Not supported by this provider" });
|
||||||
return;
|
return;
|
||||||
@@ -241,7 +281,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/user/playlists", requireNotGuest, async (req, res) => {
|
router.get("/user/playlists", requireNotGuest, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
if (!provider.getUserPlaylists) {
|
if (!provider.getUserPlaylists) {
|
||||||
res.status(501).json({ error: "Not supported by this provider" });
|
res.status(501).json({ error: "Not supported by this provider" });
|
||||||
return;
|
return;
|
||||||
@@ -256,7 +297,8 @@ export function createMusicRouter(
|
|||||||
|
|
||||||
router.get("/playlist/:id/detail", async (req, res) => {
|
router.get("/playlist/:id/detail", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(req.query.platform as string);
|
const provider = resolveProvider(req.query.platform, res);
|
||||||
|
if (!provider) return;
|
||||||
if (!provider.getPlaylistDetail) {
|
if (!provider.getPlaylistDetail) {
|
||||||
res.status(501).json({ error: "Not supported by this provider" });
|
res.status(501).json({ error: "Not supported by this provider" });
|
||||||
return;
|
return;
|
||||||
@@ -290,6 +332,114 @@ export function createMusicRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enabled sources + default platform, for the web UI (source tabs, default
|
||||||
|
// search/playback source). Without a config (unit-test routers) everything
|
||||||
|
// reports enabled with the legacy netease default.
|
||||||
|
router.get("/providers", (_req, res) => {
|
||||||
|
const ALL_PLATFORMS = [
|
||||||
|
"jellyfin",
|
||||||
|
"netease",
|
||||||
|
"qq",
|
||||||
|
"bilibili",
|
||||||
|
"youtube",
|
||||||
|
"kugou",
|
||||||
|
"spotify",
|
||||||
|
"local",
|
||||||
|
];
|
||||||
|
res.json({
|
||||||
|
enabled: config ? ALL_PLATFORMS.filter((p) => isProviderEnabled(config, p)) : ALL_PLATFORMS,
|
||||||
|
default: config ? defaultPlatform(config) : "netease",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Jellyfin home-page data ───────────────────────────────────────────
|
||||||
|
// The concrete JellyfinProvider surface these endpoints consume; the router
|
||||||
|
// only knows the MusicProvider interface, so narrow structurally (same
|
||||||
|
// pattern as localProvider.uploadAudio above).
|
||||||
|
type JellyfinHomeProvider = MusicProvider & {
|
||||||
|
getLatestAlbums?: (limit?: number) => Promise<Album[]>;
|
||||||
|
getMostPlayed?: (limit?: number) => Promise<Song[]>;
|
||||||
|
getFavoriteSongs?: (limit?: number) => Promise<Song[]>;
|
||||||
|
getGenres?: (limit?: number) => Promise<{ id: string; name: string }[]>;
|
||||||
|
getGenreSongs?: (genreId: string, limit?: number) => Promise<Song[]>;
|
||||||
|
};
|
||||||
|
const jellyfinHome = jellyfinProvider as JellyfinHomeProvider | undefined;
|
||||||
|
|
||||||
|
/** 501 when no provider is wired, 400 when the source is disabled. */
|
||||||
|
function jellyfinOrReject(res: Response): JellyfinHomeProvider | null {
|
||||||
|
if (!jellyfinHome) {
|
||||||
|
res.status(501).json({ error: "Jellyfin provider not configured" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (config && !isProviderEnabled(config, "jellyfin")) {
|
||||||
|
res.status(400).json({ error: "音源未启用:jellyfin (provider disabled)" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return jellyfinHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/jellyfin/latest-albums", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const p = jellyfinOrReject(res);
|
||||||
|
if (!p) return;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 12;
|
||||||
|
res.json({ albums: (await p.getLatestAlbums?.(limit)) ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin latest albums failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/jellyfin/most-played", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const p = jellyfinOrReject(res);
|
||||||
|
if (!p) return;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 12;
|
||||||
|
res.json({ songs: (await p.getMostPlayed?.(limit)) ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin most played failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Favorites are account-level data — guests don't get them (matches the
|
||||||
|
// requireNotGuest gate on /user/playlists).
|
||||||
|
router.get("/jellyfin/favorites", requireNotGuest, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const p = jellyfinOrReject(res);
|
||||||
|
if (!p) return;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 100;
|
||||||
|
res.json({ songs: (await p.getFavoriteSongs?.(limit)) ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin favorites failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/jellyfin/genres", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const p = jellyfinOrReject(res);
|
||||||
|
if (!p) return;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 30;
|
||||||
|
res.json({ genres: (await p.getGenres?.(limit)) ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin genres failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/jellyfin/genre/:id/songs", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const p = jellyfinOrReject(res);
|
||||||
|
if (!p) return;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 100;
|
||||||
|
res.json({ songs: (await p.getGenreSongs?.(req.params.id, limit)) ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Jellyfin genre songs failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get current quality
|
// Get current quality
|
||||||
router.get("/quality", requireNotGuest, (_req, res) => {
|
router.get("/quality", requireNotGuest, (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -299,6 +449,7 @@ export function createMusicRouter(
|
|||||||
local: localProvider?.getQuality() ?? "original",
|
local: localProvider?.getQuality() ?? "original",
|
||||||
kugou: kugouProvider?.getQuality() ?? "128",
|
kugou: kugouProvider?.getQuality() ?? "128",
|
||||||
spotify: spotifyProvider?.getQuality() ?? "320",
|
spotify: spotifyProvider?.getQuality() ?? "320",
|
||||||
|
jellyfin: jellyfinProvider?.getQuality() ?? "direct",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,6 +475,11 @@ export function createMusicRouter(
|
|||||||
if ((!platform || platform === "spotify") && spotifyProvider) {
|
if ((!platform || platform === "spotify") && spotifyProvider) {
|
||||||
spotifyProvider.setQuality(quality);
|
spotifyProvider.setQuality(quality);
|
||||||
}
|
}
|
||||||
|
// Safe in the platform-less broadcast: JellyfinProvider.setQuality ignores
|
||||||
|
// values outside its own tier list (direct/320/192/128).
|
||||||
|
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
|
||||||
|
jellyfinProvider.setQuality(quality);
|
||||||
|
}
|
||||||
logger.info({ quality, platform }, "Audio quality changed");
|
logger.info({ quality, platform }, "Audio quality changed");
|
||||||
res.json({ success: true, quality });
|
res.json({ success: true, quality });
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-15
@@ -40,6 +40,10 @@ export function createPlayerRouter(
|
|||||||
if (platform === "qq") return "-q";
|
if (platform === "qq") return "-q";
|
||||||
if (platform === "youtube") return "-y";
|
if (platform === "youtube") return "-y";
|
||||||
if (platform === "kugou") return "-k";
|
if (platform === "kugou") return "-k";
|
||||||
|
if (platform === "jellyfin") return "-j";
|
||||||
|
// The flag-less default is now the configured default platform (jellyfin
|
||||||
|
// unless disabled), so netease needs an explicit flag.
|
||||||
|
if (platform === "netease") return "-n";
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +57,11 @@ export function createPlayerRouter(
|
|||||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requesterName(req: any): string {
|
||||||
|
const name = req.user?.username;
|
||||||
|
return typeof name === "string" && name.trim() ? name.trim() : "游客";
|
||||||
|
}
|
||||||
|
|
||||||
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
@@ -66,7 +75,7 @@ export function createPlayerRouter(
|
|||||||
res.status(400).json({ error: "Invalid command" });
|
res.status(400).json({ error: "Invalid command" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await bot.executeCommand(cmd);
|
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||||
res.json({ message: response });
|
res.json({ message: response });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
@@ -82,7 +91,7 @@ export function createPlayerRouter(
|
|||||||
res.status(400).json({ error: "Invalid command" });
|
res.status(400).json({ error: "Invalid command" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await bot.executeCommand(cmd);
|
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||||
res.json({ message: response });
|
res.json({ message: response });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
@@ -116,11 +125,11 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = bot.getProviderFor(
|
const provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "netease"
|
||||||
);
|
);
|
||||||
const message = await bot.startFm(provider);
|
const message = await bot.startFm(provider, requesterName(req));
|
||||||
res.json({
|
res.json({
|
||||||
ok:
|
ok:
|
||||||
!message.startsWith("No FM songs") &&
|
!message.startsWith("No FM songs") &&
|
||||||
@@ -271,7 +280,7 @@ export function createPlayerRouter(
|
|||||||
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
|
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
|
||||||
"!"
|
"!"
|
||||||
)!;
|
)!;
|
||||||
const response = await bot.executeCommand(cmd);
|
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
|
||||||
res.json({ message: response });
|
res.json({ message: response });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
@@ -291,7 +300,7 @@ export function createPlayerRouter(
|
|||||||
// Use the bot's own provider lookup — it already knows about youtube,
|
// Use the bot's own provider lookup — it already knows about youtube,
|
||||||
// which the router's constructor params did not.
|
// which the router's constructor params did not.
|
||||||
const provider = bot.getProviderFor(
|
const provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "netease"
|
||||||
);
|
);
|
||||||
@@ -332,7 +341,7 @@ export function createPlayerRouter(
|
|||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
for (const song of queueable) {
|
for (const song of queueable) {
|
||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||||
}
|
}
|
||||||
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
|
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
|
||||||
// released and deleted, but an empty/failed playlist (early return above)
|
// released and deleted, but an empty/failed playlist (early return above)
|
||||||
@@ -384,7 +393,7 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = bot.getProviderFor(
|
const provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "netease"
|
||||||
);
|
);
|
||||||
@@ -418,7 +427,7 @@ export function createPlayerRouter(
|
|||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
for (const song of queueable) {
|
for (const song of queueable) {
|
||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||||
}
|
}
|
||||||
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
// Sweep AFTER the queue is rebuilt (see play-playlist).
|
||||||
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
bot.cleanupQueuedLocalSongs?.("queue_replaced");
|
||||||
@@ -468,7 +477,7 @@ export function createPlayerRouter(
|
|||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
bot.getPlayer().stop();
|
bot.getPlayer().stop();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
queue.add(song);
|
queue.add({ ...song, requestedBy: requesterName(req) });
|
||||||
queue.play();
|
queue.play();
|
||||||
|
|
||||||
bot.getPlayer().resetFailures();
|
bot.getPlayer().resetFailures();
|
||||||
@@ -513,7 +522,7 @@ export function createPlayerRouter(
|
|||||||
// after natural track end without queue.clear()).
|
// after natural track end without queue.clear()).
|
||||||
const insertedAt =
|
const insertedAt =
|
||||||
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||||
queue.addNext(song);
|
queue.addNext({ ...song, requestedBy: requesterName(req) });
|
||||||
|
|
||||||
if (wasIdle) {
|
if (wasIdle) {
|
||||||
// Promote the just-added song to current and start it.
|
// Promote the just-added song to current and start it.
|
||||||
@@ -555,7 +564,7 @@ export function createPlayerRouter(
|
|||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
const insertedAt =
|
const insertedAt =
|
||||||
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||||
queue.addNext(song);
|
queue.addNext({ ...song, requestedBy: requesterName(req) });
|
||||||
queue.playAt(insertedAt);
|
queue.playAt(insertedAt);
|
||||||
bot.getPlayer().resetFailures();
|
bot.getPlayer().resetFailures();
|
||||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||||
@@ -587,7 +596,7 @@ export function createPlayerRouter(
|
|||||||
const body = await bot.runExclusive(async () => {
|
const body = await bot.runExclusive(async () => {
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
const wasIdle = bot.getPlayer().getState() === "idle";
|
const wasIdle = bot.getPlayer().getState() === "idle";
|
||||||
queue.add(song);
|
queue.add({ ...song, requestedBy: requesterName(req) });
|
||||||
|
|
||||||
// If nothing was playing, start this newly-added song immediately.
|
// If nothing was playing, start this newly-added song immediately.
|
||||||
if (wasIdle) {
|
if (wasIdle) {
|
||||||
@@ -615,7 +624,7 @@ export function createPlayerRouter(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = bot.getProviderFor(
|
const provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "netease"
|
||||||
);
|
);
|
||||||
@@ -627,7 +636,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
|
||||||
|
|
||||||
// If nothing is playing, start the first song
|
// If nothing is playing, start the first song
|
||||||
if (bot.getPlayer().getState() === "idle") {
|
if (bot.getPlayer().getState() === "idle") {
|
||||||
@@ -678,6 +687,7 @@ export function createPlayerRouter(
|
|||||||
coverUrl: r.coverUrl,
|
coverUrl: r.coverUrl,
|
||||||
platform: r.platform,
|
platform: r.platform,
|
||||||
playedAt: r.playedAt,
|
playedAt: r.playedAt,
|
||||||
|
requestedBy: r.requestedBy,
|
||||||
}));
|
}));
|
||||||
res.json({ history });
|
res.json({ history });
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
|
|||||||
import { createSpotifyRouter } from "./api/spotify.js";
|
import { createSpotifyRouter } from "./api/spotify.js";
|
||||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||||
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
||||||
|
import type { JellyfinProvider } from "../music/jellyfin.js";
|
||||||
import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";
|
import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";
|
||||||
import {
|
import {
|
||||||
isGoLibrespotPresent,
|
isGoLibrespotPresent,
|
||||||
@@ -49,6 +50,7 @@ export interface WebServerOptions {
|
|||||||
localProvider: MusicProvider;
|
localProvider: MusicProvider;
|
||||||
kugouProvider: MusicProvider;
|
kugouProvider: MusicProvider;
|
||||||
spotifyProvider: MusicProvider;
|
spotifyProvider: MusicProvider;
|
||||||
|
jellyfinProvider: MusicProvider;
|
||||||
database: BotDatabase;
|
database: BotDatabase;
|
||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
@@ -141,11 +143,15 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
|
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
|
||||||
// it as the wider MusicProvider, so narrow it here for the setCreds contract.
|
// it as the wider MusicProvider, so narrow it here for the setCreds contract.
|
||||||
options.spotifyProvider as SpotifyProvider,
|
options.spotifyProvider as SpotifyProvider,
|
||||||
|
// Same live-reconfigure contract for Jellyfin: a Settings save re-points
|
||||||
|
// the connection without a restart. Runtime object is a JellyfinProvider
|
||||||
|
// (see index.ts); WebServerOptions types it as the wider MusicProvider.
|
||||||
|
options.jellyfinProvider as JellyfinProvider,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
"/api/music",
|
"/api/music",
|
||||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider)
|
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
|
||||||
);
|
);
|
||||||
app.use("/api/player", createPlayerRouter(
|
app.use("/api/player", createPlayerRouter(
|
||||||
options.botManager, logger, options.database,
|
options.botManager, logger, options.database,
|
||||||
@@ -153,7 +159,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
));
|
));
|
||||||
app.use(
|
app.use(
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.config)
|
||||||
);
|
);
|
||||||
if (options.spotifyOAuth) {
|
if (options.spotifyOAuth) {
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -7,8 +7,13 @@
|
|||||||
<span class="song-name">{{ song.name }}</span>
|
<span class="song-name">{{ song.name }}</span>
|
||||||
<span
|
<span
|
||||||
class="platform-badge"
|
class="platform-badge"
|
||||||
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'"
|
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : song.platform === 'jellyfin' ? 'badge-jellyfin' : 'badge-netease'"
|
||||||
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
|
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : song.platform === 'jellyfin' ? 'Jellyfin' : '网易云' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="song.requestedBy"
|
||||||
|
class="requester-badge"
|
||||||
|
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
|
||||||
|
>{{ song.requestedBy }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-artist">{{ song.artist }}</div>
|
<div class="song-artist">{{ song.artist }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requester-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 96px;
|
||||||
|
font-size: var(--fs-micro);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--color-online-15);
|
||||||
|
color: var(--color-online);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requester-badge-guest {
|
||||||
|
background: rgba(156, 163, 175, 0.18);
|
||||||
|
color: rgba(156, 163, 175, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.badge-netease {
|
.badge-netease {
|
||||||
background: var(--brand-netease-15);
|
background: var(--brand-netease-15);
|
||||||
color: var(--brand-netease);
|
color: var(--brand-netease);
|
||||||
@@ -150,6 +175,11 @@ function formatDuration(seconds: number): string {
|
|||||||
color: var(--brand-spotify);
|
color: var(--brand-spotify);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-jellyfin {
|
||||||
|
background: var(--brand-jellyfin-12);
|
||||||
|
color: var(--brand-jellyfin);
|
||||||
|
}
|
||||||
|
|
||||||
.song-artist {
|
.song-artist {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import type { Source } from '../stores/player.js';
|
import type { Source } from '../stores/player.js';
|
||||||
|
|
||||||
const LABELS: Record<Source, string> = {
|
const LABELS: Record<Source, string> = {
|
||||||
|
jellyfin: 'Jellyfin',
|
||||||
netease: '网易云',
|
netease: '网易云',
|
||||||
qq: 'QQ',
|
qq: 'QQ',
|
||||||
kugou: '酷狗',
|
kugou: '酷狗',
|
||||||
|
|||||||
+139
-22
@@ -10,10 +10,21 @@ export interface Song {
|
|||||||
album: string;
|
album: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
|
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify' | 'jellyfin';
|
||||||
|
requestedBy?: string;
|
||||||
|
playedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
|
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||||
|
|
||||||
|
export interface AlbumItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotStatus {
|
export interface BotStatus {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -99,12 +110,22 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
timings: {} as Record<string, TimingState>,
|
timings: {} as Record<string, TimingState>,
|
||||||
theme: 'dark' as 'dark' | 'light',
|
theme: 'dark' as 'dark' | 'light',
|
||||||
|
|
||||||
|
// Which sources the server has enabled (GET /api/music/providers) and the
|
||||||
|
// configured default. Empty until fetched — sections stay hidden briefly.
|
||||||
|
enabledProviders: [] as string[],
|
||||||
|
defaultSource: 'netease' as string,
|
||||||
|
|
||||||
// Home page cache, split by source
|
// Home page cache, split by source
|
||||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||||
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
|
||||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
userPlaylists: { jellyfin: [] as PlaylistItem[], netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
|
||||||
bilibiliPopular: [] as Song[],
|
bilibiliPopular: [] as Song[],
|
||||||
authStatus: { netease: false, qq: false, kugou: false, spotify: false },
|
// Jellyfin home sections (shown only when the optional source is enabled)
|
||||||
|
jellyfinLatestAlbums: [] as AlbumItem[],
|
||||||
|
jellyfinMostPlayed: [] as Song[],
|
||||||
|
jellyfinFavorites: [] as Song[],
|
||||||
|
jellyfinGenres: [] as { id: string; name: string }[],
|
||||||
|
authStatus: { jellyfin: false, netease: false, qq: false, kugou: false, spotify: false },
|
||||||
lastFetchTime: 0,
|
lastFetchTime: 0,
|
||||||
|
|
||||||
// Favorited playlists (fetched from server, isolated per WebUI user)
|
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||||
@@ -152,15 +173,20 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||||
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
return interpolateElapsed(timing, this.isPaused, maxDuration);
|
||||||
},
|
},
|
||||||
/** Sources that are currently logged in. Order: netease before qq. */
|
/** Sources that are currently logged in. Jellyfin (when enabled) leads. */
|
||||||
availableSources(): Source[] {
|
availableSources(): Source[] {
|
||||||
const s: Source[] = [];
|
const s: Source[] = [];
|
||||||
|
if (this.authStatus.jellyfin) s.push('jellyfin');
|
||||||
if (this.authStatus.netease) s.push('netease');
|
if (this.authStatus.netease) s.push('netease');
|
||||||
if (this.authStatus.qq) s.push('qq');
|
if (this.authStatus.qq) s.push('qq');
|
||||||
if (this.authStatus.kugou) s.push('kugou');
|
if (this.authStatus.kugou) s.push('kugou');
|
||||||
if (this.authStatus.spotify) s.push('spotify');
|
if (this.authStatus.spotify) s.push('spotify');
|
||||||
return s;
|
return s;
|
||||||
},
|
},
|
||||||
|
/** Whether a source is enabled server-side (enabledProviders gate). */
|
||||||
|
sourceEnabled(): (platform: string) => boolean {
|
||||||
|
return (platform: string) => this.enabledProviders.includes(platform);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -569,27 +595,50 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Load the server-side source gate (enabledProviders + default). */
|
||||||
|
async fetchProviders() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/music/providers');
|
||||||
|
this.enabledProviders = res.data.enabled ?? [];
|
||||||
|
if (typeof res.data.default === 'string') this.defaultSource = res.data.default;
|
||||||
|
} catch {
|
||||||
|
// keep previous values (backend briefly unavailable)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchHomeData() {
|
async fetchHomeData() {
|
||||||
|
// The provider gate decides which sources to even ask about — disabled
|
||||||
|
// sources are skipped entirely (their sidecars may not be running).
|
||||||
|
await this.fetchProviders();
|
||||||
|
const has = (p: string) => this.enabledProviders.includes(p);
|
||||||
|
const skip = Promise.reject(new Error('provider disabled'));
|
||||||
|
// Pre-rejected sentinel: mark handled so it never surfaces as unhandled.
|
||||||
|
skip.catch(() => {});
|
||||||
|
|
||||||
// Always check auth status first — if it changed since the cached
|
// Always check auth status first — if it changed since the cached
|
||||||
// fetch (e.g., user logged in/out as a different account), the
|
// fetch (e.g., user logged in/out as a different account), the
|
||||||
// cached playlists belong to a different user and we MUST refetch.
|
// cached playlists belong to a different user and we MUST refetch.
|
||||||
const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
|
const [jfAuthRes, neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
|
||||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
has('jellyfin') ? axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }) : skip,
|
||||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
has('netease') ? axios.get('/api/auth/status', { params: { platform: 'netease' } }) : skip,
|
||||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
has('qq') ? axios.get('/api/auth/status', { params: { platform: 'qq' } }) : skip,
|
||||||
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
|
has('kugou') ? axios.get('/api/auth/status', { params: { platform: 'kugou' } }) : skip,
|
||||||
|
has('spotify') ? axios.get('/api/auth/status', { params: { platform: 'spotify' } }) : skip,
|
||||||
]);
|
]);
|
||||||
const newAuth = {
|
const newAuth = {
|
||||||
|
jellyfin: jfAuthRes.status === 'fulfilled' && !!jfAuthRes.value.data?.loggedIn,
|
||||||
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
|
||||||
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
|
||||||
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
|
||||||
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
|
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
|
||||||
};
|
};
|
||||||
const authChanged =
|
const authChanged =
|
||||||
|
newAuth.jellyfin !== this.authStatus.jellyfin ||
|
||||||
newAuth.netease !== this.authStatus.netease ||
|
newAuth.netease !== this.authStatus.netease ||
|
||||||
newAuth.qq !== this.authStatus.qq ||
|
newAuth.qq !== this.authStatus.qq ||
|
||||||
newAuth.kugou !== this.authStatus.kugou ||
|
newAuth.kugou !== this.authStatus.kugou ||
|
||||||
newAuth.spotify !== this.authStatus.spotify;
|
newAuth.spotify !== this.authStatus.spotify;
|
||||||
|
this.authStatus.jellyfin = newAuth.jellyfin;
|
||||||
this.authStatus.netease = newAuth.netease;
|
this.authStatus.netease = newAuth.netease;
|
||||||
this.authStatus.qq = newAuth.qq;
|
this.authStatus.qq = newAuth.qq;
|
||||||
this.authStatus.kugou = newAuth.kugou;
|
this.authStatus.kugou = newAuth.kugou;
|
||||||
@@ -610,17 +659,23 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
|
|
||||||
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
||||||
// playlists need login but Promise.allSettled isolates failures.
|
// playlists need login but Promise.allSettled isolates failures.
|
||||||
const neteasePromises = [
|
const emptyPlaylists = { data: { playlists: [] } };
|
||||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
|
const emptySongs = { data: { songs: [] } };
|
||||||
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
|
const neteasePromises = has('netease')
|
||||||
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
|
? [
|
||||||
];
|
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
|
||||||
|
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
|
||||||
|
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
Promise.resolve(emptyPlaylists),
|
||||||
|
Promise.resolve(emptySongs),
|
||||||
|
Promise.resolve(emptyPlaylists),
|
||||||
|
];
|
||||||
|
|
||||||
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
|
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
|
||||||
// resolve to empty payloads so the same indexed handling works.
|
// resolve to empty payloads so the same indexed handling works.
|
||||||
const emptyPlaylists = { data: { playlists: [] } };
|
const qqPromises = has('qq') && this.authStatus.qq
|
||||||
const emptySongs = { data: { songs: [] } };
|
|
||||||
const qqPromises = this.authStatus.qq
|
|
||||||
? [
|
? [
|
||||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
|
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
|
||||||
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
|
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
|
||||||
@@ -634,7 +689,7 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
|
|
||||||
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
// 4. Kugou data: every section (incl. recommend playlists) needs login,
|
||||||
// so gate all three on auth like QQ.
|
// so gate all three on auth like QQ.
|
||||||
const kugouPromises = this.authStatus.kugou
|
const kugouPromises = has('kugou') && this.authStatus.kugou
|
||||||
? [
|
? [
|
||||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
|
axios.get('/api/music/recommend/playlists', { params: { platform: 'kugou' } }),
|
||||||
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
|
axios.get('/api/music/recommend/songs', { params: { platform: 'kugou' } }),
|
||||||
@@ -646,16 +701,49 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
Promise.resolve(emptyPlaylists),
|
Promise.resolve(emptyPlaylists),
|
||||||
];
|
];
|
||||||
|
|
||||||
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
|
const biliPromise = has('bilibili')
|
||||||
|
? axios.get('/api/music/bilibili/popular?limit=12')
|
||||||
|
: Promise.resolve(emptySongs);
|
||||||
|
|
||||||
|
// 5. Jellyfin home sections. Favorites are guest-blocked server-side;
|
||||||
|
// allSettled turns that 403 into an empty section.
|
||||||
|
const jellyfinPromises = has('jellyfin')
|
||||||
|
? [
|
||||||
|
axios.get('/api/music/jellyfin/latest-albums', { params: { limit: 12 } }),
|
||||||
|
axios.get('/api/music/jellyfin/most-played', { params: { limit: 12 } }),
|
||||||
|
axios.get('/api/music/jellyfin/favorites', { params: { limit: 12 } }),
|
||||||
|
axios.get('/api/music/jellyfin/genres'),
|
||||||
|
axios.get('/api/music/user/playlists', { params: { platform: 'jellyfin' } }),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
Promise.resolve({ data: { albums: [] } }),
|
||||||
|
Promise.resolve(emptySongs),
|
||||||
|
Promise.resolve(emptySongs),
|
||||||
|
Promise.resolve({ data: { genres: [] } }),
|
||||||
|
Promise.resolve(emptyPlaylists),
|
||||||
|
];
|
||||||
|
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
...neteasePromises,
|
...neteasePromises,
|
||||||
...qqPromises,
|
...qqPromises,
|
||||||
...kugouPromises,
|
...kugouPromises,
|
||||||
biliPromise,
|
biliPromise,
|
||||||
|
...jellyfinPromises,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili] = results;
|
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, kgRecPL, kgDaily, kgUserPL, bili,
|
||||||
|
jfLatest, jfMost, jfFav, jfGenres, jfUserPL] = results;
|
||||||
|
|
||||||
|
this.jellyfinLatestAlbums =
|
||||||
|
jfLatest.status === 'fulfilled' ? (jfLatest.value.data.albums ?? []) : [];
|
||||||
|
this.jellyfinMostPlayed =
|
||||||
|
jfMost.status === 'fulfilled' ? (jfMost.value.data.songs ?? []) : [];
|
||||||
|
this.jellyfinFavorites =
|
||||||
|
jfFav.status === 'fulfilled' ? (jfFav.value.data.songs ?? []) : [];
|
||||||
|
this.jellyfinGenres =
|
||||||
|
jfGenres.status === 'fulfilled' ? (jfGenres.value.data.genres ?? []) : [];
|
||||||
|
this.userPlaylists.jellyfin =
|
||||||
|
jfUserPL.status === 'fulfilled' ? (jfUserPL.value.data.playlists ?? []) : [];
|
||||||
|
|
||||||
this.recommendPlaylists.netease =
|
this.recommendPlaylists.netease =
|
||||||
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
|
||||||
@@ -684,8 +772,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
// Only mark as fetched if at least the auth-status calls succeeded —
|
// Only mark as fetched if at least the auth-status calls succeeded —
|
||||||
// a fully failed fetch (network blip / server down) should NOT be
|
// a fully failed fetch (network blip / server down) should NOT be
|
||||||
// cached for 5 minutes, otherwise the user has to hard-reload to
|
// cached for 5 minutes, otherwise the user has to hard-reload to
|
||||||
// recover when connectivity returns.
|
// recover when connectivity returns. (Disabled providers' checks are
|
||||||
|
// pre-rejected sentinels, so only enabled ones can vouch here.)
|
||||||
const authOk =
|
const authOk =
|
||||||
|
jfAuthRes.status === 'fulfilled' ||
|
||||||
neAuthRes.status === 'fulfilled' ||
|
neAuthRes.status === 'fulfilled' ||
|
||||||
qqAuthRes.status === 'fulfilled' ||
|
qqAuthRes.status === 'fulfilled' ||
|
||||||
kugouAuthRes.status === 'fulfilled';
|
kugouAuthRes.status === 'fulfilled';
|
||||||
@@ -693,5 +783,32 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.lastFetchTime = Date.now();
|
this.lastFetchTime = Date.now();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a whole Jellyfin genre: fetch its tracks, start the first, queue
|
||||||
|
* the rest. There is no bulk-queue endpoint, so the remainder is appended
|
||||||
|
* sequentially — capped at 30 tracks to keep the request burst small.
|
||||||
|
*/
|
||||||
|
async playJellyfinGenre(genreId: string) {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/music/jellyfin/genre/${genreId}/songs`, {
|
||||||
|
params: { limit: 30 },
|
||||||
|
});
|
||||||
|
const songs: Song[] = res.data.songs ?? [];
|
||||||
|
if (songs.length === 0) {
|
||||||
|
this.notify('该流派下没有曲目', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.playSong(songs[0]);
|
||||||
|
for (const song of songs.slice(1)) {
|
||||||
|
await this.addSong(song);
|
||||||
|
}
|
||||||
|
this.notify(`已加入 ${songs.length} 首`, 'info');
|
||||||
|
this.fetchQueue();
|
||||||
|
} catch {
|
||||||
|
this.notify('播放流派失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
|
|||||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||||
const all = readAll();
|
const all = readAll();
|
||||||
const v = all[key];
|
const v = all[key];
|
||||||
return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
|
return v === 'jellyfin' || v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveTabSource(key: TabKey, value: Source): void {
|
export function saveTabSource(key: TabKey, value: Source): void {
|
||||||
|
|||||||
@@ -84,6 +84,8 @@
|
|||||||
--brand-kugou-12: rgba(44, 162, 249, 0.12);
|
--brand-kugou-12: rgba(44, 162, 249, 0.12);
|
||||||
--brand-spotify: #1DB954;
|
--brand-spotify: #1DB954;
|
||||||
--brand-spotify-12: rgba(29, 185, 84, 0.12);
|
--brand-spotify-12: rgba(29, 185, 84, 0.12);
|
||||||
|
--brand-jellyfin: #aa5cc3;
|
||||||
|
--brand-jellyfin-12: rgba(170, 92, 195, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme (default)
|
// Dark theme (default)
|
||||||
|
|||||||
+144
-18
@@ -18,10 +18,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 私人FM -->
|
<!-- 私人FM(音源按 enabledProviders 门控;Jellyfin 电台优先) -->
|
||||||
<section class="section">
|
<section v-if="fmCardCount > 0" class="section">
|
||||||
<h2 class="section-title">私人FM</h2>
|
<h2 class="section-title">私人FM</h2>
|
||||||
<div class="fm-card hover-scale" @click="playFm('netease')">
|
<div v-if="enabled('jellyfin')" class="fm-card hover-scale" @click="playFm('jellyfin')">
|
||||||
|
<div class="fm-icon-wrapper jellyfin">
|
||||||
|
<Icon icon="mdi:jellyfish" class="fm-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="fm-info">
|
||||||
|
<div class="fm-title">Jellyfin 电台</div>
|
||||||
|
<div class="fm-desc">从收藏出发的 Instant Mix 歌曲流</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
|
</div>
|
||||||
|
<div v-if="enabled('netease')" class="fm-card hover-scale" @click="playFm('netease')">
|
||||||
<div class="fm-icon-wrapper">
|
<div class="fm-icon-wrapper">
|
||||||
<Icon icon="mdi:radio" class="fm-icon" />
|
<Icon icon="mdi:radio" class="fm-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
<div v-if="enabled('qq') && store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||||
<div class="fm-icon-wrapper qq">
|
<div class="fm-icon-wrapper qq">
|
||||||
<Icon icon="mdi:radar" class="fm-icon" />
|
<Icon icon="mdi:radar" class="fm-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
<div v-if="enabled('kugou') && store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||||
<div class="fm-icon-wrapper kugou">
|
<div class="fm-icon-wrapper kugou">
|
||||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +63,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Jellyfin: 最近添加 -->
|
||||||
|
<section class="section" v-if="store.jellyfinLatestAlbums.length > 0">
|
||||||
|
<h2 class="section-title">最近添加</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="al in store.jellyfinLatestAlbums"
|
||||||
|
:key="al.id"
|
||||||
|
:to="`/album/${al.id}?platform=jellyfin`"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ al.name }}</div>
|
||||||
|
<div class="playlist-count">{{ al.artist }}</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Jellyfin: 播放最多 -->
|
||||||
|
<section class="section" v-if="store.jellyfinMostPlayed.length > 0">
|
||||||
|
<h2 class="section-title">播放最多</h2>
|
||||||
|
<div class="daily-grid">
|
||||||
|
<div
|
||||||
|
v-for="song in store.jellyfinMostPlayed"
|
||||||
|
:key="song.id"
|
||||||
|
class="daily-card hover-scale"
|
||||||
|
@click="store.playSong(song)"
|
||||||
|
>
|
||||||
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
|
<div class="daily-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Jellyfin: 收藏曲目 -->
|
||||||
|
<section class="section" v-if="store.jellyfinFavorites.length > 0">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Icon icon="mdi:star" style="color: var(--brand-jellyfin)" />
|
||||||
|
Jellyfin 收藏
|
||||||
|
</h2>
|
||||||
|
<div class="daily-grid">
|
||||||
|
<div
|
||||||
|
v-for="song in store.jellyfinFavorites"
|
||||||
|
:key="song.id"
|
||||||
|
class="daily-card hover-scale"
|
||||||
|
@click="store.playSong(song)"
|
||||||
|
>
|
||||||
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
|
<div class="daily-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Jellyfin: 流派 -->
|
||||||
|
<section class="section" v-if="store.jellyfinGenres.length > 0">
|
||||||
|
<h2 class="section-title">流派</h2>
|
||||||
|
<div class="genre-chips">
|
||||||
|
<button
|
||||||
|
v-for="g in store.jellyfinGenres"
|
||||||
|
:key="g.id"
|
||||||
|
class="genre-chip"
|
||||||
|
@click="store.playJellyfinGenre(g.id)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:music-note" />
|
||||||
|
{{ g.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 每日推荐 -->
|
<!-- 每日推荐 -->
|
||||||
<section class="section" v-if="dailyAvailable.length > 0">
|
<section class="section" v-if="dailyAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
@@ -177,16 +257,31 @@ const store = usePlayerStore();
|
|||||||
const USER_PLAYLIST_LIMIT = 20;
|
const USER_PLAYLIST_LIMIT = 20;
|
||||||
const userPlaylistsExpanded = ref(false);
|
const userPlaylistsExpanded = ref(false);
|
||||||
|
|
||||||
// Available sources per section.
|
// Server-side source gate (enabledProviders).
|
||||||
// Recommend playlists work anonymously on netease, so it's always available;
|
const enabled = (p: string) => store.enabledProviders.includes(p);
|
||||||
|
const fmCardCount = computed(() =>
|
||||||
|
['jellyfin', 'netease'].filter(enabled).length +
|
||||||
|
(enabled('qq') && store.authStatus.qq ? 1 : 0) +
|
||||||
|
(enabled('kugou') && store.authStatus.kugou ? 1 : 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Available sources per section. Jellyfin has no daily/recommend concept —
|
||||||
|
// it gets its own home sections instead — so those two tab bars only ever
|
||||||
|
// carry the legacy sources (which is also what their store maps are keyed by).
|
||||||
|
type LegacySource = Exclude<Source, 'jellyfin'>;
|
||||||
|
|
||||||
|
// Recommend playlists work anonymously on netease (when enabled);
|
||||||
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
|
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
|
||||||
const recommendAvailable = computed<Source[]>(() => {
|
const recommendAvailable = computed<LegacySource[]>(() => {
|
||||||
const s: Source[] = ['netease'];
|
const s: LegacySource[] = [];
|
||||||
if (store.authStatus.qq) s.push('qq');
|
if (enabled('netease')) s.push('netease');
|
||||||
if (store.authStatus.kugou) s.push('kugou');
|
if (enabled('qq') && store.authStatus.qq) s.push('qq');
|
||||||
|
if (enabled('kugou') && store.authStatus.kugou) s.push('kugou');
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
const dailyAvailable = computed<LegacySource[]>(() =>
|
||||||
|
store.availableSources.filter((s): s is LegacySource => s !== 'jellyfin'),
|
||||||
|
);
|
||||||
const userAvailable = computed<Source[]>(() => store.availableSources);
|
const userAvailable = computed<Source[]>(() => store.availableSources);
|
||||||
|
|
||||||
// Persisted active source per section.
|
// Persisted active source per section.
|
||||||
@@ -199,14 +294,14 @@ watch(dailySource, (v) => saveTabSource('home.daily', v));
|
|||||||
watch(userSource, (v) => saveTabSource('home.user', v));
|
watch(userSource, (v) => saveTabSource('home.user', v));
|
||||||
|
|
||||||
// Fallback when persisted source is no longer available.
|
// Fallback when persisted source is no longer available.
|
||||||
const recommendSourceSafe = computed<Source>(() =>
|
const recommendSourceSafe = computed<LegacySource>(() =>
|
||||||
recommendAvailable.value.includes(recommendSource.value)
|
(recommendAvailable.value as Source[]).includes(recommendSource.value)
|
||||||
? recommendSource.value
|
? (recommendSource.value as LegacySource)
|
||||||
: recommendAvailable.value[0] ?? 'netease'
|
: recommendAvailable.value[0] ?? 'netease'
|
||||||
);
|
);
|
||||||
const dailySourceSafe = computed<Source>(() =>
|
const dailySourceSafe = computed<LegacySource>(() =>
|
||||||
dailyAvailable.value.includes(dailySource.value)
|
(dailyAvailable.value as Source[]).includes(dailySource.value)
|
||||||
? dailySource.value
|
? (dailySource.value as LegacySource)
|
||||||
: dailyAvailable.value[0] ?? 'netease'
|
: dailyAvailable.value[0] ?? 'netease'
|
||||||
);
|
);
|
||||||
const userSourceSafe = computed<Source>(() =>
|
const userSourceSafe = computed<Source>(() =>
|
||||||
@@ -373,6 +468,11 @@ onMounted(() => {
|
|||||||
&.kugou {
|
&.kugou {
|
||||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin's brand gradient (purple → blue).
|
||||||
|
&.jellyfin {
|
||||||
|
background: linear-gradient(135deg, var(--brand-jellyfin), #00a4dc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fm-icon {
|
.fm-icon {
|
||||||
@@ -472,4 +572,30 @@ onMounted(() => {
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin 流派
|
||||||
|
.genre-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--brand-jellyfin);
|
||||||
|
background: var(--brand-jellyfin-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+176
-4
@@ -56,23 +56,35 @@
|
|||||||
<div v-if="loading" class="loading">搜索中...</div>
|
<div v-if="loading" class="loading">搜索中...</div>
|
||||||
|
|
||||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||||
|
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||||
|
(opt-in) comes first when enabled. -->
|
||||||
<div class="source-bar">
|
<div class="source-bar">
|
||||||
<button
|
<button
|
||||||
|
v-if="sourceEnabled('jellyfin')"
|
||||||
|
class="source-btn"
|
||||||
|
:class="{ active: selectedSource === 'jellyfin' }"
|
||||||
|
@click="selectedSource = 'jellyfin'"
|
||||||
|
>Jellyfin</button>
|
||||||
|
<button
|
||||||
|
v-if="sourceEnabled('netease')"
|
||||||
class="source-btn"
|
class="source-btn"
|
||||||
:class="{ active: selectedSource === 'netease' }"
|
:class="{ active: selectedSource === 'netease' }"
|
||||||
@click="selectedSource = 'netease'"
|
@click="selectedSource = 'netease'"
|
||||||
>网易云</button>
|
>网易云</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="sourceEnabled('qq')"
|
||||||
class="source-btn"
|
class="source-btn"
|
||||||
:class="{ active: selectedSource === 'qq' }"
|
:class="{ active: selectedSource === 'qq' }"
|
||||||
@click="selectedSource = 'qq'"
|
@click="selectedSource = 'qq'"
|
||||||
>QQ</button>
|
>QQ</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="sourceEnabled('bilibili')"
|
||||||
class="source-btn"
|
class="source-btn"
|
||||||
:class="{ active: selectedSource === 'bilibili' }"
|
:class="{ active: selectedSource === 'bilibili' }"
|
||||||
@click="selectedSource = 'bilibili'"
|
@click="selectedSource = 'bilibili'"
|
||||||
>B站</button>
|
>B站</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="sourceEnabled('kugou')"
|
||||||
class="source-btn"
|
class="source-btn"
|
||||||
:class="{ active: selectedSource === 'kugou' }"
|
:class="{ active: selectedSource === 'kugou' }"
|
||||||
@click="selectedSource = 'kugou'"
|
@click="selectedSource = 'kugou'"
|
||||||
@@ -165,6 +177,13 @@
|
|||||||
@add="store.addSong(song)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div v-if="showLoadMore" class="load-more-wrap">
|
||||||
|
<button class="load-more-btn" :disabled="currentLoadingMore" @click="loadMore">
|
||||||
|
<Icon v-if="currentLoadingMore" icon="mdi:loading" class="spin" />
|
||||||
|
{{ currentLoadingMore ? '加载中...' : '加载更多' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||||
@@ -180,6 +199,9 @@ import { usePlayerStore } from '../stores/player.js';
|
|||||||
import type { Song } from '../stores/player.js';
|
import type { Song } from '../stores/player.js';
|
||||||
import SongCard from '../components/SongCard.vue';
|
import SongCard from '../components/SongCard.vue';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -187,18 +209,22 @@ const router = useRouter();
|
|||||||
|
|
||||||
const SOURCE_STORAGE_KEY = 'search-source';
|
const SOURCE_STORAGE_KEY = 'search-source';
|
||||||
|
|
||||||
type SearchSource = 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
type SearchSource = 'jellyfin' | 'netease' | 'qq' | 'bilibili' | 'local' | 'kugou';
|
||||||
|
|
||||||
|
const SEARCH_SOURCES: SearchSource[] = ['jellyfin', 'netease', 'qq', 'bilibili', 'local', 'kugou'];
|
||||||
|
|
||||||
function loadSource(): SearchSource {
|
function loadSource(): SearchSource {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili' || stored === 'local' || stored === 'kugou') return stored;
|
if (SEARCH_SOURCES.includes(stored as SearchSource)) return stored as SearchSource;
|
||||||
} catch { /* localStorage blocked */ }
|
} catch { /* localStorage blocked */ }
|
||||||
return 'netease';
|
return 'netease';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabType = 'songs' | 'albums' | 'playlists';
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
const query = ref((route.query.q as string) || '');
|
||||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
const activeTab = ref<TabType>('songs');
|
||||||
const selectedSource = ref<SearchSource>(loadSource());
|
const selectedSource = ref<SearchSource>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
@@ -207,6 +233,9 @@ interface Playlist { id: string; name: string; coverUrl: string; songCount?: num
|
|||||||
const allSongs = ref<Song[]>([]);
|
const allSongs = ref<Song[]>([]);
|
||||||
const allAlbums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const allPlaylists = ref<Playlist[]>([]);
|
const allPlaylists = ref<Playlist[]>([]);
|
||||||
|
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
||||||
|
const hasMoreMap = ref<Record<string, boolean>>({});
|
||||||
|
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
@@ -230,6 +259,97 @@ const filteredPlaylists = computed(() =>
|
|||||||
|
|
||||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||||
|
|
||||||
|
// Server-side source gate (enabledProviders). Until /providers loads, the
|
||||||
|
// store list is empty — treat that as "everything shown" so a slow request
|
||||||
|
// doesn't blank the bar.
|
||||||
|
function sourceEnabled(p: string): boolean {
|
||||||
|
return store.enabledProviders.length === 0 || store.enabledProviders.includes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a disabled/stale selection to the configured default (or first enabled). */
|
||||||
|
function fixupSelectedSource() {
|
||||||
|
if (selectedSource.value === 'local' || sourceEnabled(selectedSource.value)) return;
|
||||||
|
const def = store.defaultSource as SearchSource;
|
||||||
|
selectedSource.value = SEARCH_SOURCES.includes(def) && sourceEnabled(def)
|
||||||
|
? def
|
||||||
|
: SEARCH_SOURCES.find((s) => s !== 'local' && sourceEnabled(s)) ?? 'netease';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 分页 / 加载更多 ----
|
||||||
|
function pageKey(type: TabType, source: string): string {
|
||||||
|
return `${type}:${source}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItems = computed(() => {
|
||||||
|
if (activeTab.value === 'albums') return filteredAlbums.value;
|
||||||
|
if (activeTab.value === 'playlists') return filteredPlaylists.value;
|
||||||
|
return filteredSongs.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLoadingMore = computed(() => loadingMore.value[activeTab.value]);
|
||||||
|
|
||||||
|
const currentHasMore = computed(
|
||||||
|
() => hasMoreMap.value[pageKey(activeTab.value, selectedSource.value)] ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
// 有结果、还有下一页时才显示按钮;加载中时按钮保留但禁用并显示 spinner。
|
||||||
|
const showLoadMore = computed(() => currentItems.value.length > 0 && currentHasMore.value);
|
||||||
|
|
||||||
|
function resetPagination() {
|
||||||
|
hasMoreMap.value = {};
|
||||||
|
loadingMore.value = { songs: false, albums: false, playlists: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录某个 (类型, 音源) 是否还有更多:返回条数 === PAGE_SIZE 视为还有下一页。
|
||||||
|
function setHasMore(type: TabType, source: string, returnedCount: number) {
|
||||||
|
hasMoreMap.value = {
|
||||||
|
...hasMoreMap.value,
|
||||||
|
[pageKey(type, source)]: hasMore(returnedCount, PAGE_SIZE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始 /search/all 返回的是各音源合并的首页,按音源分组统计每种类型的条数。
|
||||||
|
function recordInitialHasMore(items: { platform: string }[], type: TabType) {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const it of items) counts[it.platform] = (counts[it.platform] ?? 0) + 1;
|
||||||
|
const next = { ...hasMoreMap.value };
|
||||||
|
for (const [source, count] of Object.entries(counts)) {
|
||||||
|
next[pageKey(type, source)] = hasMore(count, PAGE_SIZE);
|
||||||
|
}
|
||||||
|
hasMoreMap.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
const type = activeTab.value;
|
||||||
|
const source = selectedSource.value;
|
||||||
|
if (loadingMore.value[type]) return;
|
||||||
|
if (!currentHasMore.value) return;
|
||||||
|
const offset = nextOffset(currentItems.value.length, PAGE_SIZE);
|
||||||
|
loadingMore.value = { ...loadingMore.value, [type]: true };
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/music/search', {
|
||||||
|
params: { q: query.value, platform: source, limit: PAGE_SIZE, offset },
|
||||||
|
});
|
||||||
|
if (type === 'albums') {
|
||||||
|
const incoming = (res.data.albums ?? []) as Album[];
|
||||||
|
allAlbums.value = mergeDedup(allAlbums.value, incoming);
|
||||||
|
setHasMore(type, source, incoming.length);
|
||||||
|
} else if (type === 'playlists') {
|
||||||
|
const incoming = (res.data.playlists ?? []) as Playlist[];
|
||||||
|
allPlaylists.value = mergeDedup(allPlaylists.value, incoming);
|
||||||
|
setHasMore(type, source, incoming.length);
|
||||||
|
} else {
|
||||||
|
const incoming = (res.data.songs ?? []) as Song[];
|
||||||
|
allSongs.value = mergeDedup(allSongs.value, incoming);
|
||||||
|
setHasMore(type, source, incoming.length);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 保留 hasMore 现状,允许用户重试。
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = { ...loadingMore.value, [type]: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist source preference
|
// Persist source preference
|
||||||
watch(selectedSource, (src) => {
|
watch(selectedSource, (src) => {
|
||||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||||
@@ -266,12 +386,16 @@ async function doSearch() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
|
resetPagination();
|
||||||
router.replace({ query: { q: query.value } });
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
allSongs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
allAlbums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
allPlaylists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
|
recordInitialHasMore(allSongs.value, 'songs');
|
||||||
|
recordInitialHasMore(allAlbums.value, 'albums');
|
||||||
|
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||||
} catch {
|
} catch {
|
||||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -357,6 +481,7 @@ function badgeLabel(platform: string): string {
|
|||||||
if (platform === 'youtube') return 'YouTube';
|
if (platform === 'youtube') return 'YouTube';
|
||||||
if (platform === 'local') return '本地';
|
if (platform === 'local') return '本地';
|
||||||
if (platform === 'kugou') return '酷狗';
|
if (platform === 'kugou') return '酷狗';
|
||||||
|
if (platform === 'jellyfin') return 'Jellyfin';
|
||||||
return '网易云';
|
return '网易云';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +491,7 @@ function badgeClass(platform: string): string {
|
|||||||
if (platform === 'youtube') return 'badge-youtube';
|
if (platform === 'youtube') return 'badge-youtube';
|
||||||
if (platform === 'local') return 'badge-local';
|
if (platform === 'local') return 'badge-local';
|
||||||
if (platform === 'kugou') return 'badge-kugou';
|
if (platform === 'kugou') return 'badge-kugou';
|
||||||
|
if (platform === 'jellyfin') return 'badge-jellyfin';
|
||||||
return 'badge-netease';
|
return 'badge-netease';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,9 +507,11 @@ async function loadLocalAudioSetting() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadLocalAudioSetting();
|
loadLocalAudioSetting();
|
||||||
if (query.value) doSearch();
|
if (query.value) doSearch();
|
||||||
|
await store.fetchProviders();
|
||||||
|
fixupSelectedSource();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -601,6 +729,45 @@ onMounted(() => {
|
|||||||
.result-section {
|
.result-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 8px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast), background var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: rgba(51, 94, 234, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: load-more-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes load-more-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
@@ -657,6 +824,11 @@ onMounted(() => {
|
|||||||
color: var(--brand-kugou);
|
color: var(--brand-kugou);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-jellyfin {
|
||||||
|
background: var(--brand-jellyfin-12);
|
||||||
|
color: var(--brand-jellyfin);
|
||||||
|
}
|
||||||
|
|
||||||
.fav-badge {
|
.fav-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|||||||
+276
-19
@@ -165,12 +165,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
|
<!-- Jellyfin — optional self-hosted music source. Admin-configured
|
||||||
|
connection (server URL + credentials), no QR flow. The card stays
|
||||||
|
visible even while the source is disabled: the enable toggle below is
|
||||||
|
the only UI that flips its enabledProviders membership, so hiding the
|
||||||
|
card would leave no way to turn Jellyfin on. -->
|
||||||
<section v-if="can('platform.auth')" class="settings-section">
|
<section v-if="can('platform.auth')" class="settings-section">
|
||||||
|
<h2 class="section-title">Jellyfin 音乐库(可选)</h2>
|
||||||
|
|
||||||
|
<div class="account-card">
|
||||||
|
<div class="account-header">
|
||||||
|
<Icon icon="mdi:jellyfish" class="account-icon jellyfin-icon" />
|
||||||
|
<div class="account-info">
|
||||||
|
<div class="account-name">Jellyfin</div>
|
||||||
|
<div class="account-status" :class="{ logged: jellyfinAuth.loggedIn }">
|
||||||
|
{{ jellyfinAuth.loggedIn ? `已连接: ${jellyfinAuth.nickname}` : '未连接' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable (opt-in source: presence in enabledProviders) -->
|
||||||
|
<label class="profile-toggle behavior-toggle spotify-toggle">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">启用 Jellyfin 音源</div>
|
||||||
|
<div class="profile-toggle-hint">开启后 Jellyfin 出现在搜索、首页与聊天命令(!play -j)的音源列表中。默认关闭,点击下方「保存」后生效。</div>
|
||||||
|
</div>
|
||||||
|
<input v-model="jellyfinEnabledForm" type="checkbox" class="profile-toggle-switch" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>服务器地址</label>
|
||||||
|
<input v-model="jellyfinForm.serverUrl" class="input" autocomplete="off" placeholder="https://jellyfin.example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row spotify-row">
|
||||||
|
<div class="setting-label">认证方式</div>
|
||||||
|
<div class="spotify-btn-group">
|
||||||
|
<button
|
||||||
|
class="spotify-choice"
|
||||||
|
:class="{ active: jellyfinForm.authMode === 'userpass' }"
|
||||||
|
@click="jellyfinForm.authMode = 'userpass'"
|
||||||
|
>账号密码</button>
|
||||||
|
<button
|
||||||
|
class="spotify-choice"
|
||||||
|
:class="{ active: jellyfinForm.authMode === 'apikey' }"
|
||||||
|
@click="jellyfinForm.authMode = 'apikey'"
|
||||||
|
>API Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="jellyfinForm.authMode === 'userpass'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="jellyfinForm.username" class="input" autocomplete="off" placeholder="Jellyfin 用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<span class="spotify-secret-hint" :class="{ set: jellyfinHasPassword }">
|
||||||
|
{{ jellyfinHasPassword ? '已设置' : '未设置' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="jellyfinForm.password"
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="留空表示不修改已保存的密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
API Key
|
||||||
|
<span class="spotify-secret-hint" :class="{ set: jellyfinHasApiKey }">
|
||||||
|
{{ jellyfinHasApiKey ? '已设置' : '未设置' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="jellyfinForm.apiKey"
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="留空表示不修改已保存的 API Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户 ID</label>
|
||||||
|
<input v-model="jellyfinForm.userId" class="input" autocomplete="off" placeholder="该 Key 对应使用的 Jellyfin 用户 ID" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="spotify-actions">
|
||||||
|
<button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
|
||||||
|
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfin">
|
||||||
|
{{ jellyfinSaving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="jellyfinMessage" class="spotify-message" :class="`tone-${jellyfinMessageTone}`">{{ jellyfinMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Music Account - QR Code Login (platform auth) requires platform.auth.
|
||||||
|
Cards for sources disabled via enabledProviders are hidden. -->
|
||||||
|
<section v-if="can('platform.auth') && (providerOn('netease') || providerOn('qq') || providerOn('bilibili') || providerOn('kugou'))" class="settings-section">
|
||||||
<h2 class="section-title">音乐账号</h2>
|
<h2 class="section-title">音乐账号</h2>
|
||||||
|
|
||||||
<!-- NetEase -->
|
<!-- NetEase -->
|
||||||
<div class="account-card">
|
<div v-if="providerOn('netease')" class="account-card">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<Icon icon="mdi:cloud-outline" class="account-icon" />
|
<Icon icon="mdi:cloud-outline" class="account-icon" />
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
@@ -240,7 +346,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QQ Music -->
|
<!-- QQ Music -->
|
||||||
<div class="account-card">
|
<div v-if="providerOn('qq')" class="account-card">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<Icon icon="mdi:music-circle-outline" class="account-icon" />
|
<Icon icon="mdi:music-circle-outline" class="account-icon" />
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
@@ -309,7 +415,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- BiliBili -->
|
<!-- BiliBili -->
|
||||||
<div class="account-card">
|
<div v-if="providerOn('bilibili')" class="account-card">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
|
<Icon icon="mdi:video-outline" class="account-icon bilibili-icon" />
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
@@ -379,7 +485,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kugou -->
|
<!-- Kugou -->
|
||||||
<div class="account-card">
|
<div v-if="providerOn('kugou')" class="account-card">
|
||||||
<div class="account-header">
|
<div class="account-header">
|
||||||
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
|
<Icon icon="mdi:music-circle-outline" class="account-icon kugou-icon" />
|
||||||
<div class="account-info">
|
<div class="account-info">
|
||||||
@@ -552,7 +658,25 @@
|
|||||||
<!-- Audio Quality requires quality -->
|
<!-- Audio Quality requires quality -->
|
||||||
<section v-if="can('quality')" class="settings-section">
|
<section v-if="can('quality')" class="settings-section">
|
||||||
<h2 class="section-title">音质设置</h2>
|
<h2 class="section-title">音质设置</h2>
|
||||||
<div class="setting-row">
|
<div v-if="providerOn('jellyfin')" class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:jellyfish" class="setting-icon" />
|
||||||
|
Jellyfin 音质
|
||||||
|
</div>
|
||||||
|
<div class="quality-options">
|
||||||
|
<button
|
||||||
|
v-for="q in jellyfinQualityLevels"
|
||||||
|
:key="q.value"
|
||||||
|
class="quality-btn"
|
||||||
|
:class="{ active: jellyfinQuality === q.value }"
|
||||||
|
@click="setJellyfinQuality(q.value)"
|
||||||
|
>
|
||||||
|
<div class="quality-name">{{ q.label }}</div>
|
||||||
|
<div class="quality-desc">{{ q.desc }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="providerOn('netease') || providerOn('qq') || providerOn('kugou') || providerOn('bilibili')" class="setting-row">
|
||||||
<div class="setting-label">
|
<div class="setting-label">
|
||||||
<Icon icon="mdi:music-note-eighth" class="setting-icon" />
|
<Icon icon="mdi:music-note-eighth" class="setting-icon" />
|
||||||
音源质量
|
音源质量
|
||||||
@@ -984,10 +1108,21 @@ const qualityLevels = [
|
|||||||
{ value: 'jymaster', label: '超清母带', desc: '最高质量' },
|
{ value: 'jymaster', label: '超清母带', desc: '最高质量' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Jellyfin quality tiers (direct = untranscoded original; the rest are
|
||||||
|
// server-side transcodes for low-bandwidth Jellyfin links).
|
||||||
|
const jellyfinQuality = ref('direct');
|
||||||
|
const jellyfinQualityLevels = [
|
||||||
|
{ value: 'direct', label: '原始直传', desc: 'Direct(无转码)' },
|
||||||
|
{ value: '320', label: '320kbps', desc: '服务器转码' },
|
||||||
|
{ value: '192', label: '192kbps', desc: '服务器转码' },
|
||||||
|
{ value: '128', label: '128kbps', desc: '服务器转码' },
|
||||||
|
];
|
||||||
|
|
||||||
async function loadQuality() {
|
async function loadQuality() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/quality');
|
const res = await axios.get('/api/music/quality');
|
||||||
currentQuality.value = res.data.netease || 'exhigh';
|
currentQuality.value = res.data.netease || 'exhigh';
|
||||||
|
jellyfinQuality.value = res.data.jellyfin || 'direct';
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,6 +1133,13 @@ async function setQuality(q: string) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setJellyfinQuality(q: string) {
|
||||||
|
jellyfinQuality.value = q;
|
||||||
|
try {
|
||||||
|
await axios.post('/api/music/quality', { quality: q, platform: 'jellyfin' });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Login mode: 'qr' | 'cookie' | null
|
// Login mode: 'qr' | 'cookie' | null
|
||||||
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
|
const neteaseLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||||
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
|
const qqLoginMode = ref<'qr' | 'cookie' | null>(null);
|
||||||
@@ -1009,6 +1151,108 @@ const neteaseAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
|||||||
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
const qqAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||||
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
const bilibiliAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||||
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
const kugouAuth = reactive({ loggedIn: false, nickname: '', avatarUrl: '' });
|
||||||
|
const jellyfinAuth = reactive({ loggedIn: false, nickname: '' });
|
||||||
|
|
||||||
|
// Server-side source gate (enabledProviders): cards for disabled sources are
|
||||||
|
// hidden. Empty until GET /api/bot/settings responds — treat as all-on so a
|
||||||
|
// slow load doesn't blank the page.
|
||||||
|
const enabledProviders = ref<string[]>([]);
|
||||||
|
function providerOn(p: string): boolean {
|
||||||
|
return enabledProviders.value.length === 0 || enabledProviders.value.includes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Jellyfin connection (admin-configured; password/apiKey are write-only) ---
|
||||||
|
const jellyfinForm = reactive({
|
||||||
|
serverUrl: '',
|
||||||
|
authMode: 'userpass' as 'userpass' | 'apikey',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
apiKey: '',
|
||||||
|
userId: '',
|
||||||
|
});
|
||||||
|
const jellyfinHasPassword = ref(false);
|
||||||
|
const jellyfinHasApiKey = ref(false);
|
||||||
|
const jellyfinSaving = ref(false);
|
||||||
|
const jellyfinTesting = ref(false);
|
||||||
|
const jellyfinMessage = ref('');
|
||||||
|
const jellyfinMessageTone = ref<'ok' | 'warn'>('ok');
|
||||||
|
// Jellyfin is opt-in: its "enabled" bit is membership in enabledProviders
|
||||||
|
// (unlike Spotify's dedicated spotify.enabled flag). Only trust the toggle
|
||||||
|
// once the real list has loaded, so an early save can't clobber the other
|
||||||
|
// providers with the empty placeholder list.
|
||||||
|
const jellyfinEnabledForm = ref(false);
|
||||||
|
const enabledProvidersLoaded = ref(false);
|
||||||
|
|
||||||
|
// Populate from the masked GET /api/bot/settings response — secrets never
|
||||||
|
// round-trip, only hasPassword/hasApiKey.
|
||||||
|
function applyJellyfinConfig(jf: any) {
|
||||||
|
if (!jf || typeof jf !== 'object') return;
|
||||||
|
jellyfinForm.serverUrl = typeof jf.serverUrl === 'string' ? jf.serverUrl : '';
|
||||||
|
if (jf.authMode === 'userpass' || jf.authMode === 'apikey') jellyfinForm.authMode = jf.authMode;
|
||||||
|
jellyfinForm.username = typeof jf.username === 'string' ? jf.username : '';
|
||||||
|
jellyfinForm.userId = typeof jf.userId === 'string' ? jf.userId : '';
|
||||||
|
jellyfinForm.password = ''; // blank on load; blank on save = unchanged
|
||||||
|
jellyfinForm.apiKey = '';
|
||||||
|
jellyfinHasPassword.value = Boolean(jf.hasPassword);
|
||||||
|
jellyfinHasApiKey.value = Boolean(jf.hasApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJellyfin() {
|
||||||
|
jellyfinSaving.value = true;
|
||||||
|
jellyfinMessage.value = '';
|
||||||
|
try {
|
||||||
|
// enabledProviders is a full-replace field, so only send it when the
|
||||||
|
// current list is known — otherwise we'd wipe the other sources.
|
||||||
|
const payload: Record<string, unknown> = { jellyfin: { ...jellyfinForm } };
|
||||||
|
if (enabledProvidersLoaded.value) {
|
||||||
|
const others = enabledProviders.value.filter((p) => p !== 'jellyfin');
|
||||||
|
payload.enabledProviders = jellyfinEnabledForm.value ? [...others, 'jellyfin'] : others;
|
||||||
|
}
|
||||||
|
const res = await axios.post('/api/bot/settings', payload);
|
||||||
|
applyJellyfinConfig(res.data?.jellyfin);
|
||||||
|
if (Array.isArray(res.data?.enabledProviders)) {
|
||||||
|
enabledProviders.value = res.data.enabledProviders;
|
||||||
|
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
|
||||||
|
enabledProvidersLoaded.value = true;
|
||||||
|
// Search bar / home sections / FM cards react without a reload.
|
||||||
|
store.fetchProviders();
|
||||||
|
}
|
||||||
|
jellyfinMessageTone.value = 'ok';
|
||||||
|
jellyfinMessage.value = '已保存';
|
||||||
|
await checkAuthStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
jellyfinMessageTone.value = 'warn';
|
||||||
|
jellyfinMessage.value = err?.response?.status === 403
|
||||||
|
? '没有权限修改设置(需要 bot.manage)'
|
||||||
|
: '保存失败,请稍后重试';
|
||||||
|
} finally {
|
||||||
|
jellyfinSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trips /System/Info with the CURRENT form values (empty secret fields
|
||||||
|
// fall back to the stored ones server-side), without touching saved config.
|
||||||
|
async function testJellyfin() {
|
||||||
|
jellyfinTesting.value = true;
|
||||||
|
jellyfinMessage.value = '';
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfinForm });
|
||||||
|
if (res.data?.ok) {
|
||||||
|
jellyfinMessageTone.value = 'ok';
|
||||||
|
jellyfinMessage.value = `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`;
|
||||||
|
} else {
|
||||||
|
jellyfinMessageTone.value = 'warn';
|
||||||
|
jellyfinMessage.value = `连接失败:${res.data?.error ?? '未知错误'}`;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
jellyfinMessageTone.value = 'warn';
|
||||||
|
jellyfinMessage.value = err?.response?.status === 403
|
||||||
|
? '没有权限执行平台登录(需要 platform.auth)'
|
||||||
|
: '测试请求失败,请稍后重试';
|
||||||
|
} finally {
|
||||||
|
jellyfinTesting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// QR state
|
// QR state
|
||||||
interface QrState {
|
interface QrState {
|
||||||
@@ -1039,19 +1283,22 @@ function getQrState(platform: string): QrState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuthStatus() {
|
async function checkAuthStatus() {
|
||||||
try {
|
// allSettled: one slow/unconfigured platform must not hide the others'
|
||||||
const [nRes, qRes, bRes, kRes] = await Promise.all([
|
// status (jellyfin probes its server with a real HTTP round-trip).
|
||||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
const [nRes, qRes, bRes, kRes, jRes] = await Promise.allSettled([
|
||||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||||
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||||
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
axios.get('/api/auth/status', { params: { platform: 'bilibili' } }),
|
||||||
]);
|
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
|
||||||
Object.assign(neteaseAuth, nRes.data);
|
axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }),
|
||||||
Object.assign(qqAuth, qRes.data);
|
]);
|
||||||
Object.assign(bilibiliAuth, bRes.data);
|
if (nRes.status === 'fulfilled') Object.assign(neteaseAuth, nRes.value.data);
|
||||||
Object.assign(kugouAuth, kRes.data);
|
if (qRes.status === 'fulfilled') Object.assign(qqAuth, qRes.value.data);
|
||||||
} catch {
|
if (bRes.status === 'fulfilled') Object.assign(bilibiliAuth, bRes.value.data);
|
||||||
// API not ready
|
if (kRes.status === 'fulfilled') Object.assign(kugouAuth, kRes.value.data);
|
||||||
|
if (jRes.status === 'fulfilled') {
|
||||||
|
jellyfinAuth.loggedIn = Boolean(jRes.value.data?.loggedIn);
|
||||||
|
jellyfinAuth.nickname = jRes.value.data?.nickname ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1500,12 @@ async function loadIdleTimeout() {
|
|||||||
applyGuestModeFromServer(res.data.guestMode);
|
applyGuestModeFromServer(res.data.guestMode);
|
||||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||||
applySpotifyConfig(res.data.spotify);
|
applySpotifyConfig(res.data.spotify);
|
||||||
|
applyJellyfinConfig(res.data.jellyfin);
|
||||||
|
if (Array.isArray(res.data.enabledProviders)) {
|
||||||
|
enabledProviders.value = res.data.enabledProviders;
|
||||||
|
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
|
||||||
|
enabledProvidersLoaded.value = true;
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1989,6 +2242,10 @@ onUnmounted(() => {
|
|||||||
&.kugou-icon {
|
&.kugou-icon {
|
||||||
color: var(--brand-kugou);
|
color: var(--brand-kugou);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.jellyfin-icon {
|
||||||
|
color: var(--brand-jellyfin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-name {
|
.account-name {
|
||||||
|
|||||||
+107
-5
@@ -60,12 +60,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentStep === 2" class="step-content">
|
<div v-if="currentStep === 2" class="step-content">
|
||||||
<h2>登录音乐账号 (可选)</h2>
|
<h2>连接 Jellyfin (可选)</h2>
|
||||||
<p class="subtitle">登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲</p>
|
<p class="subtitle">连接自建 Jellyfin 服务器作为额外音源;保存后自动启用,也可稍后在「设置」中配置</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>服务器地址</label>
|
||||||
|
<input v-model="jellyfin.serverUrl" placeholder="https://jellyfin.example.com" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>认证方式</label>
|
||||||
|
<select v-model="jellyfin.authMode" class="input">
|
||||||
|
<option value="userpass">账号密码</option>
|
||||||
|
<option value="apikey">API Key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<template v-if="jellyfin.authMode === 'userpass'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="jellyfin.username" placeholder="Jellyfin 用户名" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="jellyfin.password" type="password" placeholder="Jellyfin 密码" class="input" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input v-model="jellyfin.apiKey" type="password" placeholder="Jellyfin API Key" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户 ID</label>
|
||||||
|
<input v-model="jellyfin.userId" placeholder="该 Key 使用的用户 ID" class="input" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-if="jellyfinTestMessage" class="test-message" :class="{ ok: jellyfinTestOk }">{{ jellyfinTestMessage }}</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-secondary" @click="currentStep = 1">上一步</button>
|
<button class="btn-secondary" @click="currentStep = 1">上一步</button>
|
||||||
<button class="btn-secondary" @click="currentStep = 3">跳过</button>
|
<button class="btn-secondary" @click="currentStep = 3">跳过</button>
|
||||||
<button class="btn-primary" @click="currentStep = 3">完成登录</button>
|
<button class="btn-secondary" :disabled="jellyfinTesting" @click="testJellyfin">
|
||||||
|
{{ jellyfinTesting ? '测试中…' : '测试连接' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" :disabled="jellyfinSaving" @click="saveJellyfinAndNext">
|
||||||
|
{{ jellyfinSaving ? '保存中…' : '保存并继续' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,11 +115,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, reactive } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const currentStep = ref(0);
|
const currentStep = ref(0);
|
||||||
const stepLabels = ['欢迎', 'TS 服务器', '音乐账号', '完成'];
|
const stepLabels = ['欢迎', 'TS 服务器', 'Jellyfin', '完成'];
|
||||||
|
|
||||||
const adminPassword = ref('');
|
const adminPassword = ref('');
|
||||||
const locale = ref('zh');
|
const locale = ref('zh');
|
||||||
@@ -93,6 +130,21 @@ const nickname = ref('MusicBot');
|
|||||||
const defaultChannel = ref('');
|
const defaultChannel = ref('');
|
||||||
const channelId = ref('');
|
const channelId = ref('');
|
||||||
|
|
||||||
|
// Jellyfin — optional self-hosted music source. Skipping leaves it
|
||||||
|
// configurable later in Settings; saving here also enables it.
|
||||||
|
const jellyfin = reactive({
|
||||||
|
serverUrl: '',
|
||||||
|
authMode: 'userpass' as 'userpass' | 'apikey',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
apiKey: '',
|
||||||
|
userId: '',
|
||||||
|
});
|
||||||
|
const jellyfinTesting = ref(false);
|
||||||
|
const jellyfinSaving = ref(false);
|
||||||
|
const jellyfinTestOk = ref(false);
|
||||||
|
const jellyfinTestMessage = ref('');
|
||||||
|
|
||||||
async function createBotAndNext() {
|
async function createBotAndNext() {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/bot', {
|
await axios.post('/api/bot', {
|
||||||
@@ -109,6 +161,48 @@ async function createBotAndNext() {
|
|||||||
alert('Failed to create bot: ' + (err as Error).message);
|
alert('Failed to create bot: ' + (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testJellyfin() {
|
||||||
|
jellyfinTesting.value = true;
|
||||||
|
jellyfinTestMessage.value = '';
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/auth/jellyfin/test', { ...jellyfin });
|
||||||
|
jellyfinTestOk.value = Boolean(res.data?.ok);
|
||||||
|
jellyfinTestMessage.value = res.data?.ok
|
||||||
|
? `连接成功:${res.data.serverName ?? 'Jellyfin'} ${res.data.version ?? ''}`
|
||||||
|
: `连接失败:${res.data?.error ?? '未知错误'}`;
|
||||||
|
} catch {
|
||||||
|
jellyfinTestOk.value = false;
|
||||||
|
jellyfinTestMessage.value = '测试请求失败,请检查地址后重试';
|
||||||
|
} finally {
|
||||||
|
jellyfinTesting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJellyfinAndNext() {
|
||||||
|
jellyfinSaving.value = true;
|
||||||
|
try {
|
||||||
|
// Jellyfin is opt-in (not in the default enabledProviders), so completing
|
||||||
|
// this step also enables the source — a configured-but-dark Jellyfin would
|
||||||
|
// be baffling. enabledProviders is full-replace: fetch the current list
|
||||||
|
// and append. Only do this when a server URL was actually entered.
|
||||||
|
const payload: Record<string, unknown> = { jellyfin: { ...jellyfin } };
|
||||||
|
if (jellyfin.serverUrl.trim()) {
|
||||||
|
const cur = await axios.get('/api/bot/settings');
|
||||||
|
const ep: unknown = cur.data?.enabledProviders;
|
||||||
|
if (Array.isArray(ep) && !ep.includes('jellyfin')) {
|
||||||
|
payload.enabledProviders = [...ep, 'jellyfin'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await axios.post('/api/bot/settings', payload);
|
||||||
|
currentStep.value = 3;
|
||||||
|
} catch {
|
||||||
|
jellyfinTestOk.value = false;
|
||||||
|
jellyfinTestMessage.value = '保存失败,请稍后在「设置」中重试';
|
||||||
|
} finally {
|
||||||
|
jellyfinSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -232,6 +326,14 @@ async function createBotAndNext() {
|
|||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e67e22;
|
||||||
|
margin-top: -6px;
|
||||||
|
|
||||||
|
&.ok { color: var(--color-online, #22c55e); }
|
||||||
|
}
|
||||||
|
|
||||||
.done-step {
|
.done-step {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { itemKey, mergeDedup, hasMore, nextOffset, type Keyed } from "./searchPagination.js";
|
||||||
|
|
||||||
|
const item = (platform: string, id: string): Keyed & { label: string } => ({
|
||||||
|
platform,
|
||||||
|
id,
|
||||||
|
label: `${platform}:${id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchPagination helpers (#115)", () => {
|
||||||
|
describe("itemKey", () => {
|
||||||
|
it("builds a `${platform}:${id}` key", () => {
|
||||||
|
expect(itemKey({ platform: "netease", id: "42" })).toBe("netease:42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes same id across platforms", () => {
|
||||||
|
expect(itemKey({ platform: "qq", id: "1" })).not.toBe(itemKey({ platform: "netease", id: "1" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeDedup", () => {
|
||||||
|
it("appends incoming items, existing first, order preserved", () => {
|
||||||
|
const existing = [item("netease", "1"), item("netease", "2")];
|
||||||
|
const incoming = [item("netease", "3"), item("netease", "4")];
|
||||||
|
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3", "4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops incoming items already present in existing", () => {
|
||||||
|
const existing = [item("netease", "1"), item("netease", "2")];
|
||||||
|
const incoming = [item("netease", "2"), item("netease", "3")];
|
||||||
|
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops duplicates within the incoming batch", () => {
|
||||||
|
const existing = [item("netease", "1")];
|
||||||
|
const incoming = [item("netease", "2"), item("netease", "2"), item("netease", "3")];
|
||||||
|
expect(mergeDedup(existing, incoming).map((x) => x.id)).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats same id on different platforms as distinct", () => {
|
||||||
|
const existing = [item("netease", "1")];
|
||||||
|
const incoming = [item("qq", "1")];
|
||||||
|
const merged = mergeDedup(existing, incoming);
|
||||||
|
expect(merged.map(itemKey)).toEqual(["netease:1", "qq:1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the existing array", () => {
|
||||||
|
const existing = [item("netease", "1")];
|
||||||
|
const before = existing.slice();
|
||||||
|
mergeDedup(existing, [item("netease", "2")]);
|
||||||
|
expect(existing).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty incoming", () => {
|
||||||
|
const existing = [item("netease", "1")];
|
||||||
|
expect(mergeDedup(existing, []).map((x) => x.id)).toEqual(["1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasMore", () => {
|
||||||
|
it("is true when a full page came back", () => {
|
||||||
|
expect(hasMore(20, 20)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when a short page came back", () => {
|
||||||
|
expect(hasMore(7, 20)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when nothing came back", () => {
|
||||||
|
expect(hasMore(0, 20)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextOffset", () => {
|
||||||
|
it("returns the page-aligned offset for a full first page", () => {
|
||||||
|
expect(nextOffset(20, 20)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when nothing is shown yet", () => {
|
||||||
|
expect(nextOffset(0, 20)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds up to the next page boundary after dedup drops items", () => {
|
||||||
|
// page1 (20) + page2 minus 5 dupes -> 35 shown, next page cursor is 40.
|
||||||
|
expect(nextOffset(35, 20)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays aligned across multiple full pages", () => {
|
||||||
|
expect(nextOffset(40, 20)).toBe(40);
|
||||||
|
expect(nextOffset(60, 20)).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Pure pagination helpers for Search.vue "加载更多" (load-more) per source + tab.
|
||||||
|
// Kept framework-free so root vitest can unit-cover the logic (see searchPagination.test.ts).
|
||||||
|
|
||||||
|
/** Minimal shape shared by songs / albums / playlists: needs a stable dedup key. */
|
||||||
|
export interface Keyed {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable dedup key for a result item: `${platform}:${id}`. */
|
||||||
|
export function itemKey(item: Keyed): string {
|
||||||
|
return `${item.platform}:${item.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `incoming` into `existing`, deduped by `${platform}:${id}`.
|
||||||
|
* Order is preserved with existing items first; incoming items already present
|
||||||
|
* (or duplicated within the incoming batch) are dropped.
|
||||||
|
*/
|
||||||
|
export function mergeDedup<T extends Keyed>(existing: T[], incoming: T[]): T[] {
|
||||||
|
const seen = new Set<string>(existing.map(itemKey));
|
||||||
|
const result = existing.slice();
|
||||||
|
for (const item of incoming) {
|
||||||
|
const key = itemKey(item);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether another page might exist: a full page (=== pageSize) means keep the
|
||||||
|
* button; a short/empty page (< pageSize) means the source is exhausted.
|
||||||
|
*/
|
||||||
|
export function hasMore(returnedCount: number, pageSize: number): boolean {
|
||||||
|
return returnedCount >= pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset for the next page request. Offsets are page-aligned, so this is simply
|
||||||
|
* the number of items already shown for that source+type.
|
||||||
|
*/
|
||||||
|
export function nextOffset(currentCountForSource: number, pageSize: number): number {
|
||||||
|
return Math.ceil(currentCountForSource / pageSize) * pageSize;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user