diff --git a/.gitignore b/.gitignore
index 6ed86bf..4fef9fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@ config.json
cookies/
.superpowers/
.worktrees/
+setup.log
/bin/
scripts/navbar_bigger.png
diff --git a/README.md b/README.md
index b638577..4038114 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
TSMusicBot
- TeamSpeak 音乐机器人 — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
+ TeamSpeak 音乐机器人 — 自建 Jellyfin 音乐库为主音源,网易云 / QQ / 酷狗 / 哔哩哔哩 / YouTube / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
@@ -15,14 +15,17 @@
-
-
+
+
+
+> 本项目 fork 自 [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot)(MIT 许可),在其基础上将自建 Jellyfin 服务器改造为默认主音源;原有在线音源全部保留,可按需在配置中重新启用。
+
## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
@@ -31,7 +34,8 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
-- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源
+- **Jellyfin 主音源** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为默认音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。详见 [Jellyfin 音源(主音源)](#jellyfin-音源主音源)
+- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp)代码全部保留但**默认停用**,在 `config.json` 的 `enabledProviders` 中列出即可重新启用;**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -322,14 +326,16 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
-| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
+| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为 Jellyfin,除非已停用) |
+| `!play -j <歌名>` | 显式从 Jellyfin 搜索 |
+| `!play -n <歌名>` | 从网易云音乐搜索(默认音源不再是网易云,需显式 `-n`) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -k <歌名>` | 从酷狗音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
-| `!search <歌名> [-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
+| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
-| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
+| `!play id:` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
@@ -338,11 +344,12 @@ sudo systemctl start tsmusicbot
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode ` | 切换播放模式 |
-| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
+| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
-| `!album ` | 加载专辑 |
-| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-k`/`-b`/`-y`) |
-| `!fm` | 私人 FM(网易云,自动续播) |
+| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
+| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
+| `!fm` | 私人 FM(默认 Jellyfin:从收藏出发的 Instant Mix 电台,自动续播) |
+| `!fm -n` | 网易云私人 FM(自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
@@ -376,6 +383,15 @@ sudo systemctl start tsmusicbot
### 音质等级
+**Jellyfin(主音源)**
+
+| 等级 | 说明 |
+|------|------|
+| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
+| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
+
+**在线音源(网易云等,启用后可选)**
+
| 等级 | 码率 | 格式 | 说明 |
|------|------|------|------|
| 标准 | 128kbps | MP3 | 免费可用 |
@@ -470,6 +486,62 @@ teamspeak-music-bot/
| **图标** | @iconify/vue |
| **日志** | pino |
+## Jellyfin 音源(主音源)
+
+本 fork 把自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**默认且主要的音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。
+
+### 连接配置
+
+三种方式任选:
+
+1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置)。
+2. **WebUI** — 设置 → Jellyfin 音乐库:填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
+3. **config.json** — 手动编辑 `jellyfin` 配置块后重启。
+
+两种认证方式:
+
+| 模式 | 填写内容 | 说明 |
+|------|---------|------|
+| **账号密码**(默认) | `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": ["jellyfin"]
+}
+```
+
+> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
+
+### 功能
+
+- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
+- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
+- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
+- **电台 / FM**(`!fm` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
+- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
+- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
+- **聊天命令** — 默认音源即 Jellyfin:`!play <歌名>`、`!playlist <歌单名或GUID>`、`!album <专辑名或GUID>`、`!artist <歌手>`、`!fm` 开箱即用
+
+### enabledProviders:音源开关
+
+`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["jellyfin"]`):
+
+- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
+- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
+- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
+- 示例(Jellyfin 为主 + 保留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`
+- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效
+
## 可选:YouTube 音源
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
@@ -876,6 +948,8 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
| 项目 | 说明 |
|------|------|
+| [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot) | **本项目的上游**(MIT 许可)——完整的机器人框架、在线音源与 WebUI 均来自该项目,本 fork 在其之上加入 Jellyfin 主音源 |
+| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本 fork 的主音源) |
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
@@ -893,3 +967,5 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
## 开源许可
[MIT](LICENSE)
+
+本项目 fork 自 [ZHANGTIANYAO1/teamspeak-music-bot](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot),上游同样以 MIT 许可发布,版权归其原作者所有;本 fork 的修改部分亦以 MIT 许可发布。
diff --git a/docs/superpowers/plans/2026-07-06-jellyfin-source.md b/docs/superpowers/plans/2026-07-06-jellyfin-source.md
new file mode 100644
index 0000000..4dc3eea
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-06-jellyfin-source.md
@@ -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 + 全量构建/测试
diff --git a/package.json b/package.json
index 65f1e35..231a5f2 100644
--- a/package.json
+++ b/package.json
@@ -47,5 +47,13 @@
"tsx": "^4.21.0",
"typescript": "^6.0.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
}
}
diff --git a/src/audio/queue.ts b/src/audio/queue.ts
index 3dbb3e0..7afec2c 100644
--- a/src/audio/queue.ts
+++ b/src/audio/queue.ts
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: 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
coverUrl: string;
duration: number; // seconds
diff --git a/src/bot/instance.ts b/src/bot/instance.ts
index 4508cff..00d3711 100755
--- a/src/bot/instance.ts
+++ b/src/bot/instance.ts
@@ -6,7 +6,7 @@ import {
} from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.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 {
parseCommand,
canRunCommand,
@@ -16,7 +16,13 @@ import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.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 type { AvatarStore } from "../data/avatars.js";
import {
@@ -80,6 +86,7 @@ export interface BotInstanceOptions {
localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
+ jellyfinProvider?: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -132,6 +139,7 @@ export class BotInstance extends EventEmitter {
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
+ private jellyfinProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -155,6 +163,9 @@ export class BotInstance extends EventEmitter {
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
private effectiveDuration: number | undefined;
private playGate: Promise = 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) {
super();
@@ -167,6 +178,7 @@ export class BotInstance extends EventEmitter {
this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
+ this.jellyfinProvider = options.jellyfinProvider ?? options.neteaseProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -176,6 +188,14 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
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
// cheap and side-effect-free — nothing spawns until ensureStarted().
const spotifyBase =
@@ -312,6 +332,7 @@ export class BotInstance extends EventEmitter {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
+ this.jellyfinReporter?.onStop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
// A lifecycle change must not leave a stale auto-resume armed.
@@ -327,6 +348,7 @@ export class BotInstance extends EventEmitter {
// Fresh connection — clear any stale auto-pause flag from a prior session.
this.autoPaused = false;
this._startIdlePoller();
+ this._startJellyfinReportPoller();
});
// React near-instantly to channel membership changes. The 30s idle
@@ -397,6 +419,7 @@ export class BotInstance extends EventEmitter {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
+ this.jellyfinReporter?.onStop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
this.connected = false;
@@ -475,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 {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
@@ -659,28 +705,54 @@ 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 === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider;
+ if (platform === "jellyfin") return this.jellyfinProvider;
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 {
this.isFmMode = false;
this.fmProvider = null;
this.fmRequesterName = undefined;
}
+ /** Chat-command source flags. No flag → the configured default platform
+ * (jellyfin unless disabled). Netease is no longer the implicit default,
+ * so it gets an explicit -n flag. */
+ 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): MusicProvider {
- if (flags.has("b")) return this.bilibiliProvider;
- if (flags.has("q")) return this.qqProvider;
- if (flags.has("y")) return this.youtubeProvider;
- if (flags.has("k")) return this.kugouProvider;
- if (flags.has("s")) return this.spotifyProvider;
- return this.neteaseProvider;
+ for (const [flag, platform] of BotInstance.FLAG_PLATFORMS) {
+ if (flags.has(flag)) {
+ this.assertProviderEnabled(platform);
+ return this.getProviderFor(platform);
+ }
+ }
+ const def = defaultPlatform(this.config);
+ this.assertProviderEnabled(def);
+ return this.getProviderFor(def);
}
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
@@ -793,6 +865,7 @@ export class BotInstance extends EventEmitter {
this.player.resume();
}
this.currentSourceIsSpotify = true;
+ this.jellyfinReporter?.onStop();
song.url = result.url;
// No trial clip for Spotify — full-track duration only (the near-end
// stall watchdog is disabled for the external stream anyway).
@@ -823,6 +896,11 @@ export class BotInstance extends EventEmitter {
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration;
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
// change won't try to "resume" a track the user already restarted.
this.autoPaused = false;
@@ -878,6 +956,7 @@ export class BotInstance extends EventEmitter {
// 2) id:/URL — fetch that exact song.
const ref = parseSongRef(args);
if (ref) {
+ if (ref.platform) this.assertProviderEnabled(ref.platform);
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
const song = await provider.getSongDetail(ref.id);
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
@@ -1019,6 +1098,7 @@ export class BotInstance extends EventEmitter {
}
this.currentSourceIsSpotify = false;
this.player.stop();
+ this.jellyfinReporter?.onStop();
this.autoPaused = false;
this.queue.clear();
this.sweepLocalAudio("stopped");
@@ -1081,6 +1161,7 @@ export class BotInstance extends EventEmitter {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
+ this.jellyfinReporter?.onStop();
this.queue.clear();
this.sweepLocalAudio("queue_cleared");
this.disableFmMode();
@@ -1141,14 +1222,14 @@ export class BotInstance extends EventEmitter {
if (!cmd.args) return "Usage: !playlist ";
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 isNumericId = /^\d+$/.test(cmd.args.trim());
+ const isDirectId = this.looksLikeCollectionId(cmd.args);
let playlistId: string;
- if (isNumericId || id !== cmd.args) {
- // Input is a numeric ID or URL containing an ID — use existing logic
+ if (isDirectId || id !== cmd.args) {
+ // Input is a direct ID or URL containing an ID — use existing logic
playlistId = id;
} else {
// Name-based search
@@ -1196,12 +1277,12 @@ export class BotInstance extends EventEmitter {
const provider = this.getProvider(cmd.flags);
const id = this.extractId(cmd.args);
- const isNumericId = /^\d+$/.test(cmd.args.trim());
+ const isDirectId = this.looksLikeCollectionId(cmd.args);
let albumId: string;
- if (isNumericId || id !== cmd.args) {
- // Input is a numeric ID or URL containing an ID — use directly
+ if (isDirectId || id !== cmd.args) {
+ // Input is a direct ID (numeric / Jellyfin GUID) or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
@@ -1360,15 +1441,21 @@ export class BotInstance extends EventEmitter {
private cmdHelp(): string {
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 [
"TSMusicBot Commands:",
- `${p}play — Search and play (most popular match)`,
- `${p}play -q — Search from QQ Music`,
- `${p}play -b — Search from BiliBili`,
- `${p}play -y — Search from YouTube (yt-dlp)`,
+ `${p}play — Search and play (default source: ${def})`,
+ `${p}play -j — Search from Jellyfin`,
+ ...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}search — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
- `${p}play id: — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
+ `${p}play id: — Play an exact song by id / URL`,
`${p}add — Add to queue (also accepts #N / id: / URL)`,
`${p}playnext — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
@@ -1379,11 +1466,9 @@ export class BotInstance extends EventEmitter {
`${p}remove — Remove song at position (see ${p}queue)`,
`${p}mode — Play mode`,
`${p}playlist — Load playlist by name or ID`,
- `${p}playlist -q — Load playlist from QQ Music`,
- `${p}album — Load album`,
- `${p}fm — Personal FM (NetEase)`,
+ `${p}album — Load album`,
+ `${p}fm — Personal FM (Jellyfin: 收藏电台 Instant Mix)`,
`${p}artist — Play songs by artist (loop)`,
- `${p}artist -q — Artist loop from QQ Music`,
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
`${p}now — Current song info`,
@@ -1471,6 +1556,17 @@ export class BotInstance extends EventEmitter {
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
* interleave (audible track must match queue.currentIndex). */
runExclusive(fn: () => Promise): Promise {
diff --git a/src/bot/manager.ts b/src/bot/manager.ts
index 82ee79b..ae1b011 100644
--- a/src/bot/manager.ts
+++ b/src/bot/manager.ts
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
+ private jellyfinProvider: MusicProvider;
private spotifyDataDir: string;
private readonly spotifyOAuth?: SpotifyOAuth;
private database: BotDatabase;
@@ -102,7 +103,8 @@ export class BotManager extends EventEmitter {
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
spotifyDataDir?: string,
- spotifyOAuth?: SpotifyOAuth
+ spotifyOAuth?: SpotifyOAuth,
+ jellyfinProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -112,6 +114,7 @@ export class BotManager extends EventEmitter {
this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
+ this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
this.spotifyOAuth = spotifyOAuth;
// Let the local provider see which uploads are still referenced by any
@@ -153,6 +156,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
+ jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -296,6 +300,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
+ jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
@@ -353,6 +358,7 @@ export class BotManager extends EventEmitter {
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
+ jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
diff --git a/src/data/config.ts b/src/data/config.ts
index ed8ff83..d477243 100755
--- a/src/data/config.ts
+++ b/src/data/config.ts
@@ -26,6 +26,54 @@ export interface SpotifyConfig {
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:
+ * jellyfin when enabled, otherwise the first enabled provider in a fixed
+ * priority order. 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 {
+ if (config.enabledProviders.includes("jellyfin")) return "jellyfin";
+ for (const p of ["netease", "qq", "kugou", "bilibili", "youtube"] as const) {
+ if (config.enabledProviders.includes(p)) return p;
+ }
+ return "netease";
+}
+
export interface BotConfig {
webPort: number;
locale: "zh" | "en";
@@ -51,6 +99,14 @@ export interface BotConfig {
trustProxy: boolean;
guestMode: GuestModeConfig;
spotify: SpotifyConfig;
+ jellyfin: JellyfinConfig;
+ /**
+ * Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
+ * jellyfin-only: the legacy NetEase/QQ/Bilibili/YouTube/Kugou sources keep
+ * compiling but stay disabled — their embedded sidecar API servers must not
+ * start (or bind ports 3001/3200) unless listed here.
+ */
+ enabledProviders: GateableProvider[];
}
export function getDefaultConfig(): BotConfig {
@@ -95,6 +151,15 @@ export function getDefaultConfig(): BotConfig {
deviceName: "TSMusicBot",
bitrate: 320,
},
+ jellyfin: {
+ serverUrl: "",
+ authMode: "userpass",
+ username: "",
+ password: "",
+ apiKey: "",
+ userId: "",
+ },
+ enabledProviders: ["jellyfin"],
};
}
@@ -221,12 +286,42 @@ export function loadConfig(path: string): BotConfig {
: 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;
+ 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 (["jellyfin"]). 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 {
...defaults,
...partial,
adminGroups,
guestMode: gm,
spotify,
+ jellyfin,
+ enabledProviders,
};
}
}
diff --git a/src/data/database.ts b/src/data/database.ts
index 7fd8421..992fc94 100644
--- a/src/data/database.ts
+++ b/src/data/database.ts
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
- platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
+ platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
coverUrl: string;
requestedBy?: string;
}
diff --git a/src/index.ts b/src/index.ts
index 51a5d81..9fbd974 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,6 @@
import path from "node:path";
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 { createLogger } from "./logger.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 { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js";
+import { JellyfinProvider } from "./music/jellyfin.js";
import { SpotifyProvider } from "./music/spotify/provider.js";
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
import { createCookieStore } from "./music/auth.js";
@@ -52,7 +53,14 @@ async function main() {
const db = createDatabase(DB_PATH);
const apiServer = createApiServerManager(
- { neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort },
+ {
+ neteasePort: config.neteaseApiPort,
+ qqMusicPort: config.qqMusicApiPort,
+ // Provider gating: with the default (jellyfin-only) config, neither
+ // sidecar starts and ports 3001/3200 are never bound.
+ neteaseEnabled: isProviderEnabled(config, "netease"),
+ qqEnabled: isProviderEnabled(config, "qq"),
+ },
logger
);
await apiServer.start();
@@ -82,6 +90,16 @@ async function main() {
const kugouCookie = cookieStore.load("kugou");
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);
// Single process-wide Spotify authorization (one Premium account for Stage 3).
@@ -114,7 +132,8 @@ async function main() {
kugouProvider,
spotifyProvider,
SPOTIFY_DATA_DIR,
- spotifyOAuth
+ spotifyOAuth,
+ jellyfinProvider
);
await botManager.loadSavedBots();
@@ -127,6 +146,7 @@ async function main() {
localProvider,
kugouProvider,
spotifyProvider,
+ jellyfinProvider,
database: db,
avatarStore,
config,
diff --git a/src/music/api-server.ts b/src/music/api-server.ts
index a14dd7e..08fe4b8 100644
--- a/src/music/api-server.ts
+++ b/src/music/api-server.ts
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
export interface ApiServerOptions {
neteasePort: 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 {
@@ -65,34 +69,43 @@ export function createApiServerManager(
return {
async start(): Promise {
+ // 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...");
// Start NetEase Cloud Music API
- try {
- const portFree = await isPortFree(options.neteasePort);
- if (!portFree) {
- logger.info(
- { port: options.neteasePort },
- "NetEase API port already in use — reusing existing instance"
- );
- } else {
- const ncmModule = await import("NeteaseCloudMusicApi") as any;
- const serverObj = ncmModule.server ?? ncmModule.default?.server;
- const app = await serverObj.serveNcmApi({ port: options.neteasePort });
- neteaseServer = app;
- logger.info(
- { port: options.neteasePort },
- "NetEase Cloud Music API started"
- );
+ if (options.neteaseEnabled !== false) {
+ try {
+ const portFree = await isPortFree(options.neteasePort);
+ if (!portFree) {
+ logger.info(
+ { port: options.neteasePort },
+ "NetEase API port already in use — reusing existing instance"
+ );
+ } else {
+ const ncmModule = await import("NeteaseCloudMusicApi") as any;
+ const serverObj = ncmModule.server ?? ncmModule.default?.server;
+ const app = await serverObj.serveNcmApi({ port: options.neteasePort });
+ neteaseServer = app;
+ logger.info(
+ { 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
// current fork (2.2.11+) only listens when run as `require.main`,
// so we explicitly call .listen() on the imported Koa app and keep
// the server handle for clean shutdown.
+ if (options.qqEnabled === false) return;
try {
const portFree = await isPortFree(options.qqMusicPort);
if (!portFree) {
diff --git a/src/music/auth.ts b/src/music/auth.ts
index 70b30c1..8100c8b 100644
--- a/src/music/auth.ts
+++ b/src/music/auth.ts
@@ -1,9 +1,13 @@
import fs from "node:fs";
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 {
- save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
- load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
+ save(platform: CookiePlatform, cookie: string): void;
+ load(platform: CookiePlatform): string;
}
export function createCookieStore(cookieDir: string): CookieStore {
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
}
return {
- save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
+ save(platform: CookiePlatform, cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync(
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`);
if (!fs.existsSync(filePath)) return "";
try {
diff --git a/src/music/jellyfin.test.ts b/src/music/jellyfin.test.ts
new file mode 100644
index 0000000..b874b7a
--- /dev/null
+++ b/src/music/jellyfin.test.ts
@@ -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 {
+ 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).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).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");
+ });
+});
diff --git a/src/music/jellyfin.ts b/src/music/jellyfin.ts
new file mode 100644
index 0000000..6e002c6
--- /dev/null
+++ b/src/music/jellyfin.ts
@@ -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
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 | 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 {
+ 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 {
+ 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(
+ method: "get" | "post",
+ path: string,
+ opts: { params?: Record; data?: unknown; retried?: boolean } = {},
+ ): Promise {
+ 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({
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return this.getUserPlaylists();
+ }
+
+ async getUserPlaylists(): Promise {
+ 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 {
+ 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 {
+ try {
+ const res = await this.request("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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await this.ensureAuth();
+ const items = await this.request(
+ "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 {
+ 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 {
+ 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 {
+ 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): Promise {
+ 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;
+ },
+ };
+ }
+}
diff --git a/src/music/provider.ts b/src/music/provider.ts
index c9f9e04..d63630b 100644
--- a/src/music/provider.ts
+++ b/src/music/provider.ts
@@ -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 {
id: string;
name: string;
@@ -5,7 +17,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
- platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
+ platform: Platform;
/** 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). */
vip?: boolean;
@@ -27,7 +39,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
- platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
+ platform: Platform;
}
export interface PlaylistDetail {
@@ -44,7 +56,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
- platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
+ platform: Platform;
}
export interface LyricLine {
@@ -72,7 +84,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
- readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
+ readonly platform: Platform;
search(query: string, limit?: number, offset?: number): Promise;
getSongUrl(songId: string, quality?: string): Promise;
diff --git a/src/web/api/auth.test.ts b/src/web/api/auth.test.ts
new file mode 100644
index 0000000..4598d38
--- /dev/null
+++ b/src/web/api/auth.test.ts
@@ -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,
+ 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();
+ });
+});
diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts
index d42a410..e4cd049 100644
--- a/src/web/api/auth.ts
+++ b/src/web/api/auth.ts
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
+import type { BotConfig, JellyfinConfig } from "../../data/config.js";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
@@ -13,7 +14,9 @@ export function createAuthRouter(
logger: Logger,
cookieStore?: CookieStore,
kugouProvider?: MusicProvider,
- spotifyProvider?: MusicProvider
+ spotifyProvider?: MusicProvider,
+ jellyfinProvider?: MusicProvider,
+ config?: BotConfig
): Router {
const router = Router();
// 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 === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
+ if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
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;
+ 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) => {
try {
const { phone } = req.body;
@@ -139,6 +182,14 @@ export function createAuthRouter(
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
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);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts
index 404942c..b65e61d 100644
--- a/src/web/api/bot.test.ts
+++ b/src/web/api/bot.test.ts
@@ -13,7 +13,7 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.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 type { BotManager } from "../../bot/manager.js";
@@ -561,3 +561,89 @@ describe("bot router /settings guest-mode gating + persistence", () => {
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,
+ });
+ expect(res.body.enabledProviders).toEqual(["jellyfin"]);
+ });
+
+ 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);
+ });
+});
diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts
index d242da2..fd22b8d 100755
--- a/src/web/api/bot.ts
+++ b/src/web/api/bot.ts
@@ -1,7 +1,7 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
-import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js";
-import { saveConfig } from "../../data/config.js";
+import type { BotConfig, GuestModeConfig, SpotifyConfig, JellyfinConfig, GateableProvider } from "../../data/config.js";
+import { saveConfig, GATEABLE_PROVIDERS } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.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.
// Structural type = SpotifyProvider.setCreds.
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 {
const router = Router();
@@ -39,6 +44,17 @@ export function createBotRouter(
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) => {
const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!;
@@ -59,6 +75,8 @@ export function createBotRouter(
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
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);
+ // 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)
// Client ID into the live process-wide OAuth so Connect works without a
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
@@ -166,6 +217,8 @@ export function createBotRouter(
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
spotify: maskedSpotify(),
+ jellyfin: maskedJellyfin(),
+ enabledProviders: config.enabledProviders,
});
});
diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts
index 4be3201..8d4fc1c 100644
--- a/src/web/api/music.test.ts
+++ b/src/web/api/music.test.ts
@@ -3,10 +3,12 @@ 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 {
- const empty: SearchResult = { songs: [], albums: [], playlists: [] };
return {
platform,
search: vi.fn().mockResolvedValue(empty),
@@ -47,3 +49,96 @@ describe("music router GET /search offset pagination", () => {
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 (jellyfin)", async () => {
+ const { app, netease, jellyfin } = mount(getDefaultConfig());
+ const res = await request(app).get("/api/music/search?q=hello");
+ expect(res.status).toBe(200);
+ expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
+ expect(netease.search).not.toHaveBeenCalled();
+ });
+
+ it("rejects a disabled platform with 400 without calling its provider", async () => {
+ // Default config enables jellyfin only.
+ const { app, netease } = mount(getDefaultConfig());
+ const res = await request(app).get("/api/music/search?q=hello&platform=netease");
+ expect(res.status).toBe(400);
+ expect(netease.search).not.toHaveBeenCalled();
+ });
+
+ it("allows an explicitly re-enabled legacy platform", async () => {
+ const config = getDefaultConfig();
+ config.enabledProviders = ["jellyfin", "netease"];
+ const { app, netease } = mount(config);
+ const res = await request(app).get("/api/music/search?q=hello&platform=netease");
+ expect(res.status).toBe(200);
+ expect(netease.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("jellyfin");
+ expect(res.body.enabled).toContain("jellyfin");
+ expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
+ expect(res.body.enabled).not.toContain("netease");
+ expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
+ });
+
+ it("GET /jellyfin/latest-albums returns provider data", async () => {
+ const { app, jellyfin } = mount(getDefaultConfig());
+ 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 }).getLatestAlbums,
+ ).toHaveBeenCalledWith(5);
+ expect(res.body.albums).toHaveLength(1);
+ });
+
+ it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled", async () => {
+ const config = getDefaultConfig();
+ config.enabledProviders = ["netease"];
+ const { app } = mount(config);
+ 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(getDefaultConfig());
+ const res = await request(app).get("/api/music/jellyfin/favorites");
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index 89e58eb..7e50ebe 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -1,8 +1,8 @@
-import express, { Router } from "express";
-import type { MusicProvider } from "../../music/provider.js";
+import express, { Router, type Response } from "express";
+import type { MusicProvider, Song, Album } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.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 { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
@@ -15,7 +15,8 @@ export function createMusicRouter(
localProvider?: MusicProvider,
config?: BotConfig,
kugouProvider?: MusicProvider,
- spotifyProvider?: MusicProvider
+ spotifyProvider?: MusicProvider,
+ jellyfinProvider?: MusicProvider
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -30,9 +31,26 @@ export function createMusicRouter(
if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
+ if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
+ /**
+ * Provider gating for user-supplied platform params. No platform → the
+ * configured default (jellyfin unless disabled). 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(
"/local/upload",
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
@@ -95,7 +113,8 @@ export function createMusicRouter(
res.json({ songs: [], playlists: [], albums: [] });
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);
@@ -124,15 +143,21 @@ export function createMusicRouter(
// backend lands (Stage 2/3), so surfacing them in the default all-sources
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
- const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
- neteaseProvider.search(q as string, parsedLimit),
- qqProvider.search(q as string, parsedLimit),
- bilibiliProvider.search(q as string, parsedLimit),
- localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
- kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
+ // Provider gating (#enabledProviders): disabled sources are skipped, not
+ // searched. Jellyfin — the primary source — leads the merged results.
+ const enabled = (p: string) => !config || isProviderEnabled(config, p);
+ const none = { 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 = [
+ ...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.songs : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
@@ -140,10 +165,12 @@ export function createMusicRouter(
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
];
const albums = [
+ ...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.albums : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
+ ...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.playlists : []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
@@ -161,7 +188,8 @@ export function createMusicRouter(
res.status(403).json({ error: "本地音频播放已关闭" });
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);
if (!song) {
res.status(404).json({ error: "Song not found" });
@@ -175,7 +203,8 @@ export function createMusicRouter(
router.get("/playlist/:id", async (req, res) => {
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);
res.json({ songs });
} catch (err) {
@@ -185,7 +214,8 @@ export function createMusicRouter(
router.get("/recommend/playlists", async (req, res) => {
try {
- const provider = getProvider(req.query.platform as string);
+ const provider = resolveProvider(req.query.platform, res);
+ if (!provider) return;
const playlists = await provider.getRecommendPlaylists();
res.json({ playlists });
} catch (err) {
@@ -195,7 +225,8 @@ export function createMusicRouter(
router.get("/album/:id", async (req, res) => {
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);
res.json({ songs });
} catch (err) {
@@ -205,7 +236,8 @@ export function createMusicRouter(
router.get("/lyrics/:id", async (req, res) => {
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);
res.json({ lyrics });
} catch (err) {
@@ -215,7 +247,8 @@ export function createMusicRouter(
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
try {
- const provider = getProvider(req.query.platform as string);
+ const provider = resolveProvider(req.query.platform, res);
+ if (!provider) return;
if (!provider.getDailyRecommendSongs) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -230,7 +263,8 @@ export function createMusicRouter(
router.get("/personal/fm", requireNotGuest, async (req, res) => {
try {
- const provider = getProvider(req.query.platform as string);
+ const provider = resolveProvider(req.query.platform, res);
+ if (!provider) return;
if (!provider.getPersonalFm) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -245,7 +279,8 @@ export function createMusicRouter(
router.get("/user/playlists", requireNotGuest, async (req, res) => {
try {
- const provider = getProvider(req.query.platform as string);
+ const provider = resolveProvider(req.query.platform, res);
+ if (!provider) return;
if (!provider.getUserPlaylists) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -260,7 +295,8 @@ export function createMusicRouter(
router.get("/playlist/:id/detail", async (req, res) => {
try {
- const provider = getProvider(req.query.platform as string);
+ const provider = resolveProvider(req.query.platform, res);
+ if (!provider) return;
if (!provider.getPlaylistDetail) {
res.status(501).json({ error: "Not supported by this provider" });
return;
@@ -294,6 +330,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;
+ getMostPlayed?: (limit?: number) => Promise;
+ getFavoriteSongs?: (limit?: number) => Promise;
+ getGenres?: (limit?: number) => Promise<{ id: string; name: string }[]>;
+ getGenreSongs?: (genreId: string, limit?: number) => Promise;
+ };
+ 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
router.get("/quality", requireNotGuest, (_req, res) => {
res.json({
@@ -303,6 +447,7 @@ export function createMusicRouter(
local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320",
+ jellyfin: jellyfinProvider?.getQuality() ?? "direct",
});
});
@@ -328,6 +473,11 @@ export function createMusicRouter(
if ((!platform || platform === "spotify") && spotifyProvider) {
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");
res.json({ success: true, quality });
});
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index bd49cce..ac6c5b0 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -40,6 +40,10 @@ export function createPlayerRouter(
if (platform === "qq") return "-q";
if (platform === "youtube") return "-y";
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 "";
};
@@ -121,7 +125,7 @@ export function createPlayerRouter(
return;
}
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
: "netease"
);
@@ -296,7 +300,7 @@ export function createPlayerRouter(
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
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
: "netease"
);
@@ -389,7 +393,7 @@ export function createPlayerRouter(
return;
}
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
: "netease"
);
@@ -620,7 +624,7 @@ export function createPlayerRouter(
return;
}
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
: "netease"
);
diff --git a/src/web/server.ts b/src/web/server.ts
index 78c1a9f..35d99ff 100755
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
import { createSpotifyRouter } from "./api/spotify.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.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 {
isGoLibrespotPresent,
@@ -49,6 +50,7 @@ export interface WebServerOptions {
localProvider: MusicProvider;
kugouProvider: MusicProvider;
spotifyProvider: MusicProvider;
+ jellyfinProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
@@ -141,11 +143,15 @@ export function createWebServer(options: WebServerOptions): WebServer {
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
// it as the wider MusicProvider, so narrow it here for the setCreds contract.
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(
"/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(
options.botManager, logger, options.database,
@@ -153,7 +159,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
));
app.use(
"/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) {
app.use(
diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue
index 706c34c..613dc78 100644
--- a/web/src/components/SongCard.vue
+++ b/web/src/components/SongCard.vue
@@ -7,8 +7,8 @@
{{ song.name }}
{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}
+ :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' : song.platform === 'jellyfin' ? 'Jellyfin' : '网易云' }}
= {
+ jellyfin: 'Jellyfin',
netease: '网易云',
qq: 'QQ',
kugou: '酷狗',
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index a7b7c18..c0e5677 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -10,12 +10,21 @@ export interface Song {
album: string;
duration: number;
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 {
id: string;
@@ -101,12 +110,22 @@ export const usePlayerStore = defineStore('player', {
timings: {} as Record,
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: 'jellyfin' as string,
+
// Home page cache, split by source
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[] },
- 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[],
- authStatus: { netease: false, qq: false, kugou: false, spotify: false },
+ // Jellyfin home sections (primary source)
+ 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,
// Favorited playlists (fetched from server, isolated per WebUI user)
@@ -154,15 +173,20 @@ export const usePlayerStore = defineStore('player', {
const maxDuration = this.activeBot.currentSong.duration || Infinity;
return interpolateElapsed(timing, this.isPaused, maxDuration);
},
- /** Sources that are currently logged in. Order: netease before qq. */
+ /** Sources that are currently logged in. Jellyfin (the primary source) leads. */
availableSources(): Source[] {
const s: Source[] = [];
+ if (this.authStatus.jellyfin) s.push('jellyfin');
if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou');
if (this.authStatus.spotify) s.push('spotify');
return s;
},
+ /** Whether a source is enabled server-side (enabledProviders gate). */
+ sourceEnabled(): (platform: string) => boolean {
+ return (platform: string) => this.enabledProviders.includes(platform);
+ },
},
actions: {
@@ -571,27 +595,50 @@ export const usePlayerStore = defineStore('player', {
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() {
+ // 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
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
- const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
- axios.get('/api/auth/status', { params: { platform: 'netease' } }),
- axios.get('/api/auth/status', { params: { platform: 'qq' } }),
- axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
- axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
+ const [jfAuthRes, neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
+ has('jellyfin') ? axios.get('/api/auth/status', { params: { platform: 'jellyfin' } }) : skip,
+ has('netease') ? axios.get('/api/auth/status', { params: { platform: 'netease' } }) : skip,
+ has('qq') ? axios.get('/api/auth/status', { params: { platform: 'qq' } }) : skip,
+ has('kugou') ? axios.get('/api/auth/status', { params: { platform: 'kugou' } }) : skip,
+ has('spotify') ? axios.get('/api/auth/status', { params: { platform: 'spotify' } }) : skip,
]);
const newAuth = {
+ jellyfin: jfAuthRes.status === 'fulfilled' && !!jfAuthRes.value.data?.loggedIn,
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
};
const authChanged =
+ newAuth.jellyfin !== this.authStatus.jellyfin ||
newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq ||
newAuth.kugou !== this.authStatus.kugou ||
newAuth.spotify !== this.authStatus.spotify;
+ this.authStatus.jellyfin = newAuth.jellyfin;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou;
@@ -612,17 +659,23 @@ export const usePlayerStore = defineStore('player', {
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
- const neteasePromises = [
- 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' } }),
- ];
+ const emptyPlaylists = { data: { playlists: [] } };
+ const emptySongs = { data: { songs: [] } };
+ const neteasePromises = has('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,
// resolve to empty payloads so the same indexed handling works.
- const emptyPlaylists = { data: { playlists: [] } };
- const emptySongs = { data: { songs: [] } };
- const qqPromises = this.authStatus.qq
+ const qqPromises = has('qq') && this.authStatus.qq
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
@@ -636,7 +689,7 @@ export const usePlayerStore = defineStore('player', {
// 4. Kugou data: every section (incl. recommend playlists) needs login,
// 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/songs', { params: { platform: 'kugou' } }),
@@ -648,16 +701,49 @@ export const usePlayerStore = defineStore('player', {
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([
...neteasePromises,
...qqPromises,
...kugouPromises,
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 =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
@@ -686,8 +772,10 @@ export const usePlayerStore = defineStore('player', {
// Only mark as fetched if at least the auth-status calls succeeded —
// a fully failed fetch (network blip / server down) should NOT be
// 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 =
+ jfAuthRes.status === 'fulfilled' ||
neAuthRes.status === 'fulfilled' ||
qqAuthRes.status === 'fulfilled' ||
kugouAuthRes.status === 'fulfilled';
@@ -695,5 +783,32 @@ export const usePlayerStore = defineStore('player', {
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');
+ }
+ },
},
});
diff --git a/web/src/stores/sourceTabs.ts b/web/src/stores/sourceTabs.ts
index adf19d7..6b6184d 100644
--- a/web/src/stores/sourceTabs.ts
+++ b/web/src/stores/sourceTabs.ts
@@ -28,7 +28,7 @@ function readAll(): Partial> {
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
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 {
diff --git a/web/src/styles/variables.scss b/web/src/styles/variables.scss
index 031868a..d99e248 100644
--- a/web/src/styles/variables.scss
+++ b/web/src/styles/variables.scss
@@ -84,6 +84,8 @@
--brand-kugou-12: rgba(44, 162, 249, 0.12);
--brand-spotify: #1DB954;
--brand-spotify-12: rgba(29, 185, 84, 0.12);
+ --brand-jellyfin: #aa5cc3;
+ --brand-jellyfin-12: rgba(170, 92, 195, 0.12);
}
// Dark theme (default)
diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue
index 9c107a3..a1b3f0d 100644
--- a/web/src/views/Home.vue
+++ b/web/src/views/Home.vue
@@ -18,10 +18,20 @@
-
-
+
+
私人FM
-
+
+
+
+
+
+
Jellyfin 电台
+
从收藏出发的 Instant Mix 歌曲流
+
+
+
+
-
-
+
+
+
+ 最近添加
+
+
+
+ {{ al.name }}
+ {{ al.artist }}
+
+
+
+
+
+
+ 播放最多
+
+
+
+
{{ song.name }}
+
{{ song.artist }}
+
+
+
+
+
+
+
+
+ Jellyfin 收藏
+
+
+
+
+
{{ song.name }}
+
{{ song.artist }}
+
+
+
+
+
+
+ 流派
+
+
+
+
+
@@ -177,16 +257,31 @@ const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false);
-// Available sources per section.
-// Recommend playlists work anonymously on netease, so it's always available;
+// Server-side source gate (enabledProviders).
+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;
+
+// Recommend playlists work anonymously on netease (when enabled);
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
-const recommendAvailable = computed(() => {
- const s: Source[] = ['netease'];
- if (store.authStatus.qq) s.push('qq');
- if (store.authStatus.kugou) s.push('kugou');
+const recommendAvailable = computed(() => {
+ const s: LegacySource[] = [];
+ if (enabled('netease')) s.push('netease');
+ if (enabled('qq') && store.authStatus.qq) s.push('qq');
+ if (enabled('kugou') && store.authStatus.kugou) s.push('kugou');
return s;
});
-const dailyAvailable = computed(() => store.availableSources);
+const dailyAvailable = computed(() =>
+ store.availableSources.filter((s): s is LegacySource => s !== 'jellyfin'),
+);
const userAvailable = computed(() => store.availableSources);
// Persisted active source per section.
@@ -199,14 +294,14 @@ watch(dailySource, (v) => saveTabSource('home.daily', v));
watch(userSource, (v) => saveTabSource('home.user', v));
// Fallback when persisted source is no longer available.
-const recommendSourceSafe = computed(() =>
- recommendAvailable.value.includes(recommendSource.value)
- ? recommendSource.value
+const recommendSourceSafe = computed(() =>
+ (recommendAvailable.value as Source[]).includes(recommendSource.value)
+ ? (recommendSource.value as LegacySource)
: recommendAvailable.value[0] ?? 'netease'
);
-const dailySourceSafe = computed(() =>
- dailyAvailable.value.includes(dailySource.value)
- ? dailySource.value
+const dailySourceSafe = computed(() =>
+ (dailyAvailable.value as Source[]).includes(dailySource.value)
+ ? (dailySource.value as LegacySource)
: dailyAvailable.value[0] ?? 'netease'
);
const userSourceSafe = computed(() =>
@@ -373,6 +468,11 @@ onMounted(() => {
&.kugou {
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 {
@@ -472,4 +572,30 @@ onMounted(() => {
color: var(--text-tertiary);
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);
+ }
+}
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
index 5267ced..a4111ec 100644
--- a/web/src/views/Search.vue
+++ b/web/src/views/Search.vue
@@ -56,23 +56,35 @@
搜索中...
+
+
-
-
+
+
+ Jellyfin 音乐库
+
+
+
+
+
+
+
+
+
+
+
认证方式
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ jellyfinMessage }}
+
+
+
+
+