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 歌曲流
+
+ +
+
@@ -31,7 +41,7 @@
-
+
@@ -41,7 +51,7 @@
-
+
@@ -53,6 +63,76 @@
+ +
+

最近添加

+
+ + +
{{ 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 @@
搜索中...