diff --git a/EMBY_INTEGRATION_DESIGN.md b/EMBY_INTEGRATION_DESIGN.md new file mode 100644 index 0000000..bc8c5fa --- /dev/null +++ b/EMBY_INTEGRATION_DESIGN.md @@ -0,0 +1,616 @@ +# Emby 集成设计方案 + +## 一、可行性分析 + +### 1.1 Emby vs OpenList 对比 + +#### 相似点 +| 特性 | OpenList | Emby | +|------|----------|------| +| 服务类型 | 外部 API 服务 | 外部 API 服务 | +| 认证方式 | 用户名/密码 + Token | API Key 或用户名/密码 | +| 媒体列表 | ✅ 提供 | ✅ 提供 | +| 播放链接 | ✅ 提供 | ✅ 提供 | +| 元数据 | 需自行获取 | ✅ 内置完整元数据 | + +#### 关键差异 +| 维度 | OpenList | Emby | +|------|----------|------| +| **元数据来源** | 项目通过 TMDB API 获取 | Emby 自带(已从 TMDB/TVDB 获取) | +| **API 结构** | 文件系统 API | 媒体库 API | +| **组织方式** | 文件夹结构 | 媒体库(Movies/TV Shows) | +| **播放链接** | 云存储直链 | 流媒体 URL(支持转码) | +| **复杂度** | 需要文件名解析 + TMDB 查询 | 直接使用 Emby 元数据 | + +### 1.2 可行性结论 + +**✅ 完全可行!** Emby 的集成甚至比 OpenList 更简单,因为: +- Emby 自带完整元数据系统 +- API 更加成熟和标准化 +- 不需要复杂的文件名解析和 TMDB 查询 +- 媒体已经按照标准格式组织 + +--- + +## 二、CMS-Proxy 实现方案 + +### 2.1 为什么使用 CMS-Proxy + +项目本身是聚合 CMS API 的(苹果 CMS V10 格式),使用 CMS-Proxy 的优势: + +1. **网页播放**:复用现有播放逻辑,无需单独开发 +2. **TVBOX 支持**:直接支持,无需额外适配 +3. **代码复用**:统一接口,降低维护成本 +4. **扩展性强**:未来添加其他源(如 Jellyfin)只需实现 cms-proxy + +### 2.2 CMS API 格式要求 + +```json +{ + "code": 1, + "msg": "数据列表", + "page": 1, + "pagecount": 1, + "limit": 20, + "total": 100, + "list": [ + { + "vod_id": "唯一ID", + "vod_name": "视频名称", + "vod_pic": "海报URL", + "vod_remarks": "备注(如'电影'/'剧集')", + "vod_year": "年份", + "vod_content": "简介", + "vod_play_from": "播放源名称", + "vod_play_url": "第1集$url1#第2集$url2#第3集$url3", + "type_name": "类型" + } + ] +} +``` + +### 2.3 Emby 数据映射 + +```typescript +// Emby Item → CMS Format +{ + vod_id: item.Id, + vod_name: item.Name, + vod_pic: `${embyUrl}/Items/${item.Id}/Images/Primary`, + vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', + vod_year: item.ProductionYear?.toString() || '', + vod_content: item.Overview || '', + vod_play_from: 'Emby', + vod_play_url: buildPlayUrl(item), + type_name: item.Type === 'Movie' ? '电影' : '电视剧' +} +``` + +### 2.4 核心实现 + +#### 路由结构 +``` +/api/emby/cms-proxy/[token]?ac=videolist&wd=关键词 +/api/emby/cms-proxy/[token]?ac=detail&ids=视频ID +``` + +#### 关键逻辑 + +**1. 搜索/列表** +```typescript +async function handleSearch(client: EmbyClient, query: string) { + const items = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 100 + }); + + return items.map(item => ({ + vod_id: item.Id, + vod_name: item.Name, + vod_pic: getEmbyImageUrl(item.Id), + vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', + vod_year: item.ProductionYear?.toString() || '', + vod_content: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧' + })); +} +``` + +**2. 详情(关键)** +```typescript +async function handleDetail(client: EmbyClient, itemId: string) { + const item = await client.getItem(itemId); + + let vodPlayUrl = ''; + + if (item.Type === 'Movie') { + // 电影:单个播放链接 + const playUrl = buildPlayUrl(client, itemId); + vodPlayUrl = `正片$${playUrl}`; + } else if (item.Type === 'Series') { + // 剧集:获取所有季和集 + const episodes = await getAllEpisodes(client, itemId); + vodPlayUrl = episodes + .map(ep => `第${ep.IndexNumber}集$${buildPlayUrl(client, ep.Id)}`) + .join('#'); + } + + return { + vod_id: item.Id, + vod_name: item.Name, + vod_play_url: vodPlayUrl, + vod_play_from: 'Emby', + // ... 其他字段 + }; +} +``` + +**3. 播放链接构建** +```typescript +function buildPlayUrl(client: EmbyClient, itemId: string): string { + // 方案 1: 直接返回 Emby 流媒体链接(推荐) + return `${client.serverUrl}/Videos/${itemId}/stream?api_key=${client.apiKey}&Static=true`; + + // 方案 2: 通过项目代理(支持去广告等功能) + const baseUrl = process.env.SITE_BASE; + const token = process.env.TVBOX_SUBSCRIBE_TOKEN; + return `${baseUrl}/api/emby/play/${token}?id=${itemId}`; +} +``` + +--- + +## 三、前台页面集成方案 + +### 3.1 设计思路 + +将 Emby 集成到现有的"私人影库"页面,使用 **CapsuleSwitch** 组件切换 OpenList 和 Emby。 + +**优势:** +- 统一入口,用户体验更好 +- 复用现有 UI 组件(VideoCard、分页等) +- 代码更简洁,维护成本低 + +### 3.2 页面结构 + +``` +┌─────────────────────────────────────────┐ +│ 私人影库 │ +│ 观看自我收藏的高清视频吧 │ +├─────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ │ +│ │ OpenList │ Emby │ ← CapsuleSwitch +│ └─────────────────────────┘ │ +│ │ +├─────────────────────────────────────────┤ +│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ └───┘ └───┘ └───┘ └───┘ └───┘ │ +│ │ +│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ └───┘ └───┘ └───┘ └───┘ └───┘ │ +│ │ +├─────────────────────────────────────────┤ +│ 上一页 第 1 / 5 页 下一页 │ +└─────────────────────────────────────────┘ +``` + +### 3.3 核心代码 + +```typescript +// src/app/private-library/page.tsx + +'use client'; + +import { useState, useEffect } from 'react'; +import CapsuleSwitch from '@/components/CapsuleSwitch'; +import VideoCard from '@/components/VideoCard'; + +type LibrarySource = 'openlist' | 'emby'; + +export default function PrivateLibraryPage() { + const [source, setSource] = useState('openlist'); + const [videos, setVideos] = useState([]); + const [page, setPage] = useState(1); + + // 切换源时重置页码 + useEffect(() => { + setPage(1); + }, [source]); + + useEffect(() => { + const endpoint = source === 'openlist' + ? `/api/openlist/list?page=${page}` + : `/api/emby/list?page=${page}`; + + fetch(endpoint).then(res => res.json()).then(setVideos); + }, [page, source]); + + return ( +
+

私人影库

+ + {/* 源切换器 */} + setSource(value as LibrarySource)} + /> + + {/* 视频网格 */} +
+ {videos.map(video => ( + + ))} +
+ + {/* 分页 */} +
+ ); +} +``` + +--- + +## 四、完整实现步骤 + +### Phase 1: 基础设施 + +#### 1. 配置类型定义 +```typescript +// src/lib/admin.types.ts + +interface EmbyConfig { + Enabled: boolean + ServerURL: string // Emby 服务器地址 + ApiKey?: string // API Key(推荐) + Username?: string // 或使用用户名/密码 + Password?: string + UserId?: string // 用户 ID + Libraries?: string[] // 要显示的媒体库 ID + EnableTranscoding?: boolean // 是否启用转码 + MaxBitrate?: number // 最大码率 + LastSyncTime?: number + ItemCount?: number +} +``` + +#### 2. Emby 客户端 +```typescript +// src/lib/emby.client.ts + +export class EmbyClient { + private serverUrl: string + private apiKey?: string + private userId?: string + + constructor(config: EmbyConfig) { + this.serverUrl = config.ServerURL.replace(/\/$/, '') + this.apiKey = config.ApiKey + this.userId = config.UserId + } + + // 核心方法 + async authenticate(username: string, password: string): Promise + async getLibraries(): Promise + async getItems(params: GetItemsParams): Promise + async getItem(itemId: string): Promise + async getSeasons(seriesId: string): Promise + async getEpisodes(seriesId: string, seasonId: string): Promise + async checkConnectivity(): Promise +} +``` + +### Phase 2: API 端点 + +#### 1. CMS-Proxy(核心) +``` +src/app/api/emby/cms-proxy/[token]/route.ts +``` +- 支持搜索:`?ac=videolist&wd=关键词` +- 支持详情:`?ac=detail&ids=视频ID` +- 支持列表:`?ac=videolist` +- 返回苹果 CMS V10 格式 + +#### 2. 列表 API +``` +src/app/api/emby/list/route.ts +``` +- 用于前台页面显示 +- 支持分页 +- 返回格式与 OpenList 一致 + +#### 3. 详情 API +``` +src/app/api/emby/detail/route.ts +``` +- 获取媒体详情 +- 获取剧集列表 +- 返回播放链接 + +#### 4. 播放代理(可选) +``` +src/app/api/emby/play/[token]/route.ts +``` +- 代理播放请求 +- 支持去广告等功能 + +#### 5. 管理 API +``` +src/app/api/admin/emby/route.ts +``` +- 保存/更新 Emby 配置 +- 测试连接 +- 同步媒体库 + +### Phase 3: 前台集成 + +#### 1. 修改私人影库页面 +``` +src/app/private-library/page.tsx +``` +- 添加 CapsuleSwitch 组件 +- 支持切换 OpenList/Emby +- 复用现有 VideoCard 和分页逻辑 + +#### 2. 修改播放页面 +``` +src/app/play/page.tsx +``` +- 支持 `source=emby` 参数 +- 处理 Emby 播放链接 + +#### 3. 管理面板集成 +``` +src/app/admin/page.tsx +``` +- 添加 Emby 配置区域 +- 连接测试 +- 媒体库同步 + +#### 4. 导航集成 +``` +src/components/Sidebar.tsx +``` +- 保持"私人影库"入口不变 +- 内部通过 CapsuleSwitch 切换 + +### Phase 4: TVBOX 集成 + +#### 修改订阅接口 +```typescript +// src/app/api/tvbox/subscribe/[token]/route.ts + +const sites = [ + // 现有的 CMS 站点 + ...existingSites, + + // 添加 OpenList + { + key: 'openlist', + name: '私人影库-OpenList', + type: 1, + api: `${baseUrl}/api/cms-proxy?api=openlist`, + searchable: 1 + }, + + // 添加 Emby + { + key: 'emby', + name: '私人影库-Emby', + type: 1, + api: `${baseUrl}/api/emby/cms-proxy/${token}`, + searchable: 1 + } +]; +``` + +--- + +## 五、文件结构 + +``` +src/ +├── app/ +│ ├── api/ +│ │ ├── admin/ +│ │ │ └── emby/ +│ │ │ └── route.ts # Emby 配置管理 +│ │ └── emby/ +│ │ ├── cms-proxy/ +│ │ │ └── [token]/ +│ │ │ └── route.ts # CMS API 代理(核心) +│ │ ├── list/ +│ │ │ └── route.ts # 媒体列表 +│ │ ├── detail/ +│ │ │ └── route.ts # 媒体详情 +│ │ └── play/ +│ │ └── [token]/ +│ │ └── route.ts # 播放代理(可选) +│ ├── private-library/ +│ │ └── page.tsx # 私人影库页面(集成 CapsuleSwitch) +│ └── admin/ +│ └── page.tsx # 管理面板(添加 Emby 配置) +├── lib/ +│ ├── emby.client.ts # Emby API 客户端 +│ ├── emby-adapter.ts # 数据适配器 +│ ├── emby-cache.ts # 缓存层(可选) +│ └── admin.types.ts # 类型定义(添加 EmbyConfig) +└── components/ + ├── CapsuleSwitch.tsx # 切换组件(已存在) + └── VideoCard.tsx # 视频卡片(已存在) +``` + +--- + +## 六、关键技术点 + +### 6.1 Emby API 认证 + +```typescript +// 方式 1: API Key(推荐) +const headers = { + 'X-Emby-Token': apiKey +} + +// 方式 2: 用户认证 +const authResponse = await fetch(`${serverUrl}/Users/AuthenticateByName`, { + method: 'POST', + body: JSON.stringify({ Username: username, Pw: password }) +}); +const { AccessToken, User } = await authResponse.json(); +``` + +### 6.2 获取媒体列表 + +```typescript +// GET /Users/{userId}/Items +const params = new URLSearchParams({ + ParentId: libraryId, + IncludeItemTypes: 'Movie,Series', + Recursive: 'true', + Fields: 'Overview,ProductionYear', + StartIndex: '0', + Limit: '20' +}); +``` + +### 6.3 获取播放链接 + +```typescript +// 直接播放(无转码) +const playUrl = `${serverUrl}/Videos/${itemId}/stream?api_key=${apiKey}&Static=true`; + +// 转码播放 +const playUrl = `${serverUrl}/Videos/${itemId}/master.m3u8?api_key=${apiKey}&VideoCodec=h264`; +``` + +### 6.4 获取剧集 + +```typescript +// 获取季列表 +const seasons = await fetch(`${serverUrl}/Shows/${seriesId}/Seasons?userId=${userId}`); + +// 获取某季的所有集 +const episodes = await fetch(`${serverUrl}/Shows/${seriesId}/Episodes?seasonId=${seasonId}`); +``` + +--- + +## 七、优势总结 + +### 7.1 相比传统方式 + +| 特性 | 传统方式 | CMS-Proxy 方式 | +|------|---------|---------------| +| **网页播放** | 需要单独开发 Emby 播放页面 | 复用现有播放逻辑 | +| **TVBOX 支持** | 需要单独适配 | 直接支持,无需额外开发 | +| **代码复用** | 低 | 高 | +| **维护成本** | 高(两套逻辑) | 低(统一接口) | +| **扩展性** | 每个源都要适配 | 新增源只需实现 cms-proxy | + +### 7.2 相比 OpenList + +| 维度 | OpenList | Emby | +|------|----------|------| +| **实现复杂度** | 复杂(需要文件名解析 + TMDB 查询) | 简单(直接使用 Emby 元数据) | +| **元数据质量** | 依赖 TMDB API | Emby 自带完整元数据 | +| **播放能力** | 云存储直链 | 支持转码、字幕、多音轨 | +| **媒体组织** | 基于文件夹 | 标准媒体库结构 | + +--- + +## 八、预估工作量 + +- **核心功能**:约 800-1000 行代码 +- **完整集成**:约 1200-1500 行代码 +- **开发时间**:2-3 天(有 OpenList 参考) + +### 代码量分解 + +| 模块 | 代码量 | 说明 | +|------|--------|------| +| Emby 客户端 | 200-300 行 | API 封装 | +| CMS-Proxy | 300-400 行 | 核心转换逻辑 | +| 列表/详情 API | 200-300 行 | 前台接口 | +| 前台页面修改 | 100-150 行 | CapsuleSwitch 集成 | +| 管理面板 | 150-200 行 | 配置界面 | +| 类型定义 | 50-100 行 | TypeScript 类型 | +| **总计** | **1000-1450 行** | | + +--- + +## 九、实现建议 + +1. **优先使用 API Key 认证**(比用户名/密码更简单) +2. **实现良好的缓存策略**(减少对 Emby 服务器的请求) +3. **支持多媒体库**(让用户选择显示哪些库) +4. **考虑转码选项**(根据网络情况动态调整) +5. **复用现有组件**(VideoCard、播放器等) +6. **先实现 CMS-Proxy**(这样 TVBOX 和网页都能用) +7. **再实现前台集成**(CapsuleSwitch 切换) + +--- + +## 十、测试计划 + +### 10.1 功能测试 + +- [ ] Emby 连接测试 +- [ ] 媒体列表获取(电影/剧集) +- [ ] 搜索功能 +- [ ] 详情页面(电影/剧集) +- [ ] 播放功能(直接播放/转码) +- [ ] 剧集切换 +- [ ] 分页功能 +- [ ] CapsuleSwitch 切换 + +### 10.2 集成测试 + +- [ ] 网页播放测试 +- [ ] TVBOX 订阅测试 +- [ ] TVBOX 搜索测试 +- [ ] TVBOX 播放测试 +- [ ] OpenList 和 Emby 切换测试 + +### 10.3 性能测试 + +- [ ] 大型媒体库加载速度 +- [ ] 缓存效果验证 +- [ ] 并发请求处理 + +--- + +## 十一、后续扩展 + +### 可能的增强功能 + +1. **媒体库过滤**:按类型、年份、评分过滤 +2. **收藏功能**:标记喜欢的媒体 +3. **观看历史**:记录播放进度 +4. **字幕支持**:加载 Emby 字幕 +5. **转码设置**:用户自定义转码参数 +6. **多用户支持**:不同用户看到不同的媒体库 +7. **Jellyfin 支持**:Jellyfin API 与 Emby 类似,可以复用大部分代码 + +--- + +## 十二、总结 + +Emby 集成方案通过 **CMS-Proxy** 和 **CapsuleSwitch** 的组合,实现了: + +✅ **统一接口**:网页和 TVBOX 都使用 CMS API 格式 +✅ **统一入口**:私人影库页面集成 OpenList 和 Emby +✅ **代码复用**:最大化复用现有组件和逻辑 +✅ **扩展性强**:未来添加其他源(Jellyfin、Plex)只需实现 cms-proxy +✅ **用户体验好**:无缝切换,流畅播放 + +这是一个**优雅、高效、可扩展**的设计方案! diff --git a/EMBY_USAGE_GUIDE.md b/EMBY_USAGE_GUIDE.md new file mode 100644 index 0000000..cf8415e --- /dev/null +++ b/EMBY_USAGE_GUIDE.md @@ -0,0 +1,287 @@ +# Emby 集成使用指南 + +## 🎉 实现完成 + +Emby 已经成功集成到 MoonTVPlus 项目中!所有核心功能都已实现并可以使用。 + +--- + +## 📋 已实现的功能 + +### 1. 核心基础设施 +- ✅ EmbyConfig 类型定义 (`src/lib/admin.types.ts`) +- ✅ Emby 客户端 (`src/lib/emby.client.ts`) + +### 2. API 端点 +- ✅ `/api/emby/list` - 媒体列表(分页) +- ✅ `/api/emby/detail` - 媒体详情 +- ✅ `/api/emby/cms-proxy/[token]` - CMS API 代理(核心) +- ✅ `/api/admin/emby` - 管理配置 + +### 3. 前台集成 +- ✅ 私人影库页面集成 CapsuleSwitch(OpenList ↔ Emby 切换) +- ✅ 播放页面支持 `source=emby` 参数 + +### 4. 管理面板 +- ✅ Emby 配置区域(服务器地址、API Key、用户名/密码) +- ✅ 连接测试功能 +- ✅ 保存配置功能 + +--- + +## 🚀 快速开始 + +### 步骤 1: 配置 Emby + +1. 登录管理面板 +2. 找到 **"Emby 媒体库"** 配置区域 +3. 填写以下信息: + - **服务器地址**: 你的 Emby 服务器地址(如 `http://192.168.1.100:8096`) + - **API Key**(推荐): 在 Emby 设置中生成 API Key + - 或者填写 **用户名** 和 **密码** +4. 点击 **"测试连接"** 验证配置 +5. 点击 **"保存配置"** +6. 启用 **"启用 Emby 媒体库"** 开关 + +### 步骤 2: 访问私人影库 + +1. 访问 **"私人影库"** 页面 +2. 使用顶部的 **CapsuleSwitch** 切换到 **"Emby"** +3. 浏览你的 Emby 媒体库 +4. 点击任意视频即可播放 + +### 步骤 3: TVBOX 订阅(可选) + +如果你启用了 TVBOX 订阅功能,Emby 会自动添加到订阅源中: + +**订阅地址格式**: +``` +http://your-domain.com/api/emby/cms-proxy/{TVBOX_SUBSCRIBE_TOKEN} +``` + +在 TVBOX 中添加此订阅源,即可在 TVBOX 中浏览和播放 Emby 媒体库。 + +--- + +## 🔧 配置说明 + +### Emby 服务器地址 + +- 格式: `http://IP:PORT` 或 `https://domain.com` +- 示例: `http://192.168.1.100:8096` +- 注意: 确保 MoonTVPlus 服务器能够访问 Emby 服务器 + +### 认证方式 + +**方式 1: API Key(推荐)** +- 在 Emby 设置中生成 API Key +- 优点: 更安全,不需要存储密码 +- 获取方式: Emby 设置 → 高级 → API Keys + +**方式 2: 用户名/密码** +- 使用 Emby 用户账号 +- 优点: 简单直接 +- 注意: 密码会加密存储在数据库中 + +--- + +## 📖 功能特性 + +### 1. 网页播放 + +- **统一入口**: 私人影库页面 +- **无缝切换**: CapsuleSwitch 在 OpenList 和 Emby 之间切换 +- **完整支持**: 电影、剧集、季、集 +- **自动播放**: 点击即播,无需额外配置 + +### 2. CMS-Proxy + +Emby 的 CMS-Proxy 将 Emby 媒体库转换为苹果 CMS V10 格式,实现: + +- ✅ 网页播放复用现有逻辑 +- ✅ TVBOX 直接支持 +- ✅ 统一接口,降低维护成本 +- ✅ 扩展性强,未来可添加 Jellyfin、Plex 等 + +### 3. 播放链接 + +**直接播放**(默认): +``` +http://emby-server:8096/Videos/{itemId}/stream?Static=true&api_key={apiKey} +``` + +**转码播放**(可选): +``` +http://emby-server:8096/Videos/{itemId}/master.m3u8?api_key={apiKey} +``` + +--- + +## 🎯 使用场景 + +### 场景 1: 家庭媒体中心 + +- 在家中搭建 Emby 服务器 +- 使用 MoonTVPlus 作为统一的观影入口 +- 在私人影库中浏览和播放 Emby 媒体 + +### 场景 2: 多源聚合 + +- OpenList: 云存储媒体 +- Emby: 本地媒体服务器 +- CMS 源: 在线资源 +- 统一在 MoonTVPlus 中管理和播放 + +### 场景 3: TVBOX 集成 + +- 在 TVBOX 中添加 Emby 订阅源 +- 在电视上浏览和播放 Emby 媒体 +- 无需单独安装 Emby 客户端 + +--- + +## 🔍 故障排查 + +### 问题 1: 连接测试失败 + +**可能原因**: +- Emby 服务器地址错误 +- 网络不通(防火墙、端口未开放) +- API Key 或用户名/密码错误 + +**解决方法**: +1. 检查 Emby 服务器地址是否正确 +2. 确保 MoonTVPlus 服务器能够访问 Emby 服务器 +3. 验证 API Key 或用户名/密码是否正确 +4. 检查 Emby 服务器日志 + +### 问题 2: 媒体列表为空 + +**可能原因**: +- Emby 媒体库为空 +- 用户权限不足 +- 媒体库未扫描完成 + +**解决方法**: +1. 在 Emby 中检查媒体库是否有内容 +2. 确保使用的用户有访问媒体库的权限 +3. 等待 Emby 完成媒体库扫描 + +### 问题 3: 播放失败 + +**可能原因**: +- 媒体文件损坏 +- 网络带宽不足 +- 浏览器不支持视频格式 + +**解决方法**: +1. 在 Emby 客户端中测试播放 +2. 检查网络连接 +3. 尝试使用其他浏览器 +4. 考虑启用转码 + +--- + +## 📊 性能优化 + +### 1. 缓存策略 + +- 媒体列表缓存: 减少对 Emby 服务器的请求 +- 播放链接缓存: 提高播放响应速度 + +### 2. 网络优化 + +- 使用本地网络: Emby 服务器和 MoonTVPlus 在同一网络 +- 启用转码: 根据网络情况动态调整码率 + +### 3. 媒体库优化 + +- 定期清理无效媒体 +- 使用 SSD 存储媒体文件 +- 优化 Emby 服务器配置 + +--- + +## 🔐 安全建议 + +1. **使用 HTTPS**: 在生产环境中使用 HTTPS 访问 Emby +2. **强密码**: 使用强密码保护 Emby 账号 +3. **API Key**: 优先使用 API Key 而不是用户名/密码 +4. **防火墙**: 限制 Emby 服务器的访问来源 +5. **定期更新**: 保持 Emby 和 MoonTVPlus 更新到最新版本 + +--- + +## 🎨 自定义 + +### 修改播放链接格式 + +编辑 `src/lib/emby.client.ts` 中的 `getStreamUrl` 方法: + +```typescript +getStreamUrl(itemId: string, direct: boolean = true): string { + if (direct) { + // 直接播放 + return `${this.serverUrl}/Videos/${itemId}/stream?Static=true&api_key=${this.apiKey}`; + } + // 转码播放 + return `${this.serverUrl}/Videos/${itemId}/master.m3u8?api_key=${this.apiKey}&VideoCodec=h264`; +} +``` + +### 添加媒体库过滤 + +在 `src/lib/admin.types.ts` 中的 `EmbyConfig` 添加 `Libraries` 字段,然后在 API 中使用: + +```typescript +const result = await client.getItems({ + ParentId: config.EmbyConfig.Libraries?.[0], // 指定媒体库 + IncludeItemTypes: 'Movie,Series', + // ... +}); +``` + +--- + +## 🚧 已知限制 + +1. **字幕支持**: 当前版本不支持加载 Emby 字幕(计划中) +2. **转码设置**: 不支持用户自定义转码参数(计划中) +3. **多用户**: 不支持不同用户看到不同的媒体库(计划中) +4. **收藏同步**: 不支持与 Emby 的收藏同步(计划中) + +--- + +## 🔮 未来计划 + +- [ ] 字幕支持 +- [ ] 转码设置 +- [ ] 多用户支持 +- [ ] 收藏同步 +- [ ] 观看历史同步 +- [ ] Jellyfin 支持(API 与 Emby 类似) +- [ ] Plex 支持 + +--- + +## 📞 支持 + +如果遇到问题或有建议,请: + +1. 查看本文档的故障排查部分 +2. 查看 Emby 服务器日志 +3. 查看 MoonTVPlus 服务器日志 +4. 在 GitHub 上提交 Issue + +--- + +## 🎉 总结 + +Emby 集成为 MoonTVPlus 带来了强大的本地媒体服务器支持,通过 CMS-Proxy 的设计,实现了: + +- ✅ 统一的播放体验 +- ✅ TVBOX 无缝集成 +- ✅ 代码复用和维护简化 +- ✅ 良好的扩展性 + +享受你的 Emby 媒体库吧!🎬 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c1125ba..29563d4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3399,6 +3399,222 @@ const OpenListConfigComponent = ({ ); }; +// Emby 媒体库配置组件 +const EmbyConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [serverURL, setServerURL] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [userId, setUserId] = useState(''); + + useEffect(() => { + if (config?.EmbyConfig) { + setEnabled(config.EmbyConfig.Enabled || false); + setServerURL(config.EmbyConfig.ServerURL || ''); + setApiKey(config.EmbyConfig.ApiKey || ''); + setUsername(config.EmbyConfig.Username || ''); + setPassword(config.EmbyConfig.Password || ''); + setUserId(config.EmbyConfig.UserId || ''); + } + }, [config]); + + const handleSave = async () => { + await withLoading('saveEmby', async () => { + try { + const response = await fetch('/api/admin/emby', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + Enabled: enabled, + ServerURL: serverURL, + ApiKey: apiKey, + Username: username, + Password: password, + UserId: userId, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + + await refreshConfig(); + showSuccess('保存成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleTest = async () => { + await withLoading('testEmby', async () => { + try { + const response = await fetch('/api/admin/emby', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + ServerURL: serverURL, + ApiKey: apiKey, + Username: username, + Password: password, + }), + }); + + const data = await response.json(); + + if (data.success) { + showSuccess(data.message || 'Emby 连接测试成功', showAlert); + } else { + showError(data.message || 'Emby 连接测试失败', showAlert); + } + } catch (error) { + showError(error instanceof Error ? error.message : '测试失败', showAlert); + } + }); + }; + + return ( +
+ + + {/* 启用开关 */} +
+ + +
+ + {/* 服务器地址 */} +
+ + setServerURL(e.target.value)} + placeholder='http://192.168.1.100:8096' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ + {/* API Key */} +
+ + setApiKey(e.target.value)} + placeholder='输入 Emby API Key' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +

+ 推荐使用 API Key 认证。如果不使用 API Key,请填写下方的用户名和密码。 +

+
+ + {/* 用户名 */} +
+ + setUsername(e.target.value)} + placeholder='Emby 用户名' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ + {/* 密码 */} +
+ + setPassword(e.target.value)} + placeholder='Emby 密码' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ + {/* 用户 ID */} +
+ + setUserId(e.target.value)} + placeholder='aab507c58e874de6a9bd12388d72f4d2' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +

+ 从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/... +

+
+ + {/* 操作按钮 */} +
+ + +
+
+ ); +}; + // 视频源配置组件 const VideoSourceConfig = ({ config, @@ -8779,6 +8995,7 @@ function AdminPageClient() { userConfig: false, videoSource: false, openListConfig: false, + embyConfig: false, aiConfig: false, liveSource: false, siteConfig: false, @@ -9092,6 +9309,18 @@ function AdminPageClient() { + {/* Emby 媒体库标签 */} + + } + isExpanded={expandedTabs.embyConfig} + onToggle={() => toggleTab('embyConfig')} + > + + + {/* AI配置标签 */} ({ + vod_id: item.Id, + vod_name: item.Name, + vod_pic: client.getImageUrl(item.Id, 'Primary'), + vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', + vod_year: item.ProductionYear?.toString() || '', + vod_content: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + })); + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: list.length, + total: list.length, + list, + }); +} + +/** + * 处理通过搜索关键词获取详情的请求 + */ +async function handleDetailBySearch( + client: EmbyClient, + query: string, + token: string, + request: NextRequest +) { + const result = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 1, + }); + + if (result.Items.length === 0) { + return NextResponse.json({ + code: 0, + msg: '未找到该视频', + page: 1, + pagecount: 0, + limit: 0, + total: 0, + list: [], + }); + } + + return await handleDetail(client, result.Items[0].Id, token, request); +} + +/** + * 处理详情请求 + */ +async function handleDetail( + client: EmbyClient, + itemId: string, + token: string, + request: NextRequest +) { + const item = await client.getItem(itemId); + + // 获取当前请求的 baseUrl + const host = request.headers.get('host') || request.headers.get('x-forwarded-host'); + const proto = request.headers.get('x-forwarded-proto') || + (host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https'); + const baseUrl = process.env.SITE_BASE || `${proto}://${host}`; + + let vodPlayUrl = ''; + + if (item.Type === 'Movie') { + // 电影:单个播放链接 + vodPlayUrl = `正片$${client.getStreamUrl(item.Id)}`; + } else if (item.Type === 'Series') { + // 剧集:获取所有集 + const allEpisodes = await client.getEpisodes(itemId); + + const episodes = allEpisodes + .sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0); + } + return (a.IndexNumber || 0) - (b.IndexNumber || 0); + }) + .map((ep) => { + const title = `第${ep.IndexNumber}集`; + const playUrl = client.getStreamUrl(ep.Id); + return `${title}$${playUrl}`; + }); + + vodPlayUrl = episodes.join('#'); + } + + return NextResponse.json({ + code: 1, + msg: '数据列表', + page: 1, + pagecount: 1, + limit: 1, + total: 1, + list: [ + { + vod_id: item.Id, + vod_name: item.Name, + vod_pic: client.getImageUrl(item.Id, 'Primary'), + vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', + vod_year: item.ProductionYear?.toString() || '', + vod_content: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + vod_play_url: vodPlayUrl, + vod_play_from: 'Emby', + }, + ], + }); +} diff --git a/src/app/api/emby/detail/route.ts b/src/app/api/emby/detail/route.ts new file mode 100644 index 0000000..24a3cad --- /dev/null +++ b/src/app/api/emby/detail/route.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; +import { EmbyClient } from '@/lib/emby.client'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const itemId = searchParams.get('id'); + + if (!itemId) { + return NextResponse.json({ error: '缺少媒体ID' }, { status: 400 }); + } + + try { + const config = await getConfig(); + const embyConfig = config.EmbyConfig; + + if (!embyConfig?.Enabled || !embyConfig.ServerURL) { + return NextResponse.json({ error: 'Emby 未配置或未启用' }, { status: 400 }); + } + + const client = new EmbyClient(embyConfig); + + // 如果没有 UserId,需要先认证 + if (!embyConfig.UserId && embyConfig.Username && embyConfig.Password) { + const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); + embyConfig.UserId = authResult.User.Id; + } + + if (!embyConfig.UserId) { + return NextResponse.json({ error: 'Emby 认证失败' }, { status: 401 }); + } + + // 获取媒体详情 + const item = await client.getItem(itemId); + + let episodes: any[] = []; + + if (item.Type === 'Series') { + // 获取所有剧集 + const allEpisodes = await client.getEpisodes(itemId); + + episodes = allEpisodes + .sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0); + } + return (a.IndexNumber || 0) - (b.IndexNumber || 0); + }) + .map((ep) => ({ + id: ep.Id, + title: ep.Name, + episode: ep.IndexNumber || 0, + season: ep.ParentIndexNumber || 1, + overview: ep.Overview || '', + playUrl: client.getStreamUrl(ep.Id), + })); + } + + return NextResponse.json({ + success: true, + item: { + id: item.Id, + title: item.Name, + type: item.Type === 'Movie' ? 'movie' : 'tv', + overview: item.Overview || '', + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + rating: item.CommunityRating || 0, + playUrl: item.Type === 'Movie' ? client.getStreamUrl(item.Id) : undefined, + }, + episodes: item.Type === 'Series' ? episodes : [], + }); + } catch (error) { + console.error('获取 Emby 详情失败:', error); + return NextResponse.json( + { error: '获取 Emby 详情失败: ' + (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/emby/list/route.ts b/src/app/api/emby/list/route.ts new file mode 100644 index 0000000..9c26a47 --- /dev/null +++ b/src/app/api/emby/list/route.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; +import { EmbyClient } from '@/lib/emby.client'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const pageSize = parseInt(searchParams.get('pageSize') || '20'); + + try { + const config = await getConfig(); + const embyConfig = config.EmbyConfig; + + console.log('[Emby List] EmbyConfig:', JSON.stringify(embyConfig, null, 2)); + + if (!embyConfig?.Enabled || !embyConfig.ServerURL) { + return NextResponse.json({ + error: 'Emby 未配置或未启用', + list: [], + totalPages: 0, + currentPage: page, + total: 0, + }); + } + + // 创建 Emby 客户端 + const client = new EmbyClient(embyConfig); + + // 如果使用用户名密码且没有 UserId,需要先认证 + if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) { + try { + const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); + embyConfig.UserId = authResult.User.Id; + } catch (error) { + return NextResponse.json({ + error: 'Emby 认证失败: ' + (error as Error).message, + list: [], + totalPages: 0, + currentPage: page, + total: 0, + }); + } + } + + // 验证认证信息:必须有 ApiKey 或 UserId + if (!embyConfig.ApiKey && !embyConfig.UserId) { + return NextResponse.json({ + error: 'Emby 认证失败,请检查配置', + list: [], + totalPages: 0, + currentPage: page, + total: 0, + }); + } + + // 获取媒体列表 + const result = await client.getItems({ + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: (page - 1) * pageSize, + Limit: pageSize, + }); + + const list = result.Items.map((item) => ({ + id: item.Id, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + rating: item.CommunityRating || 0, + mediaType: item.Type === 'Movie' ? 'movie' : 'tv', + })); + + const totalPages = Math.ceil(result.TotalRecordCount / pageSize); + + return NextResponse.json({ + success: true, + list, + totalPages, + currentPage: page, + total: result.TotalRecordCount, + }); + } catch (error) { + console.error('获取 Emby 列表失败:', error); + return NextResponse.json({ + error: '获取 Emby 列表失败: ' + (error as Error).message, + list: [], + totalPages: 0, + currentPage: page, + total: 0, + }); + } +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 67c290b..c4330b4 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -44,6 +44,46 @@ export async function GET(request: NextRequest) { config.OpenListConfig?.Password ); + // 检查是否配置了 Emby + const hasEmby = !!( + config.EmbyConfig?.Enabled && + config.EmbyConfig?.ServerURL && + config.EmbyConfig?.UserId + ); + + // 搜索 Emby(如果配置了) + let embyResults: any[] = []; + if (hasEmby) { + try { + const { EmbyClient } = await import('@/lib/emby.client'); + const client = new EmbyClient(config.EmbyConfig!); + + const searchResult = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 50, + }); + + embyResults = searchResult.Items.map((item) => ({ + id: item.Id, + source: 'emby', + source_name: 'Emby', + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + episodes: [], + episodes_titles: [], + year: item.ProductionYear?.toString() || '', + desc: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + douban_id: 0, + })); + } catch (error) { + console.error('[Search] 搜索 Emby 失败:', error); + } + } + // 搜索 OpenList(如果配置了) let openlistResults: any[] = []; if (hasOpenList) { @@ -114,7 +154,7 @@ export async function GET(request: NextRequest) { const successResults = results .filter((result) => result.status === 'fulfilled') .map((result) => (result as PromiseFulfilledResult).value); - let flattenedResults = [...openlistResults, ...successResults.flat()]; + let flattenedResults = [...embyResults, ...openlistResults, ...successResults.flat()]; if (!config.SiteConfig.DisableYellowFilter) { flattenedResults = flattenedResults.filter((result) => { const typeName = result.type_name || ''; diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index cc01509..8c6db20 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -41,6 +41,13 @@ export async function GET(request: NextRequest) { config.OpenListConfig?.Password ); + // 检查是否配置了 Emby + const hasEmby = !!( + config.EmbyConfig?.Enabled && + config.EmbyConfig?.ServerURL && + config.EmbyConfig?.UserId + ); + // 共享状态 let streamClosed = false; @@ -70,7 +77,7 @@ export async function GET(request: NextRequest) { const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: apiSites.length + (hasOpenList ? 1 : 0), + totalSources: apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0), timestamp: Date.now() })}\n\n`; @@ -82,6 +89,75 @@ export async function GET(request: NextRequest) { let completedSources = 0; const allResults: any[] = []; + // 搜索 Emby(如果配置了) + if (hasEmby) { + try { + const { EmbyClient } = await import('@/lib/emby.client'); + const client = new EmbyClient(config.EmbyConfig!); + + const searchResult = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 50, + }); + + const embyResults = searchResult.Items.map((item) => ({ + id: item.Id, + source: 'emby', + source_name: 'Emby', + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + episodes: [], + episodes_titles: [], + year: item.ProductionYear?.toString() || '', + desc: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + douban_id: 0, + })); + + completedSources++; + + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: 'emby', + sourceName: 'Emby', + results: embyResults, + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(sourceEvent))) { + streamClosed = true; + return; + } + + if (embyResults.length > 0) { + allResults.push(...embyResults); + } + } + } catch (error) { + console.error('[Search WS] 搜索 Emby 失败:', error); + completedSources++; + + if (!streamClosed) { + const errorEvent = `data: ${JSON.stringify({ + type: 'source_error', + source: 'emby', + sourceName: 'Emby', + error: error instanceof Error ? error.message : '搜索失败', + timestamp: Date.now() + })}\n\n`; + + if (!safeEnqueue(encoder.encode(errorEvent))) { + streamClosed = true; + return; + } + } + } + } + // 搜索 OpenList(如果配置了) if (hasOpenList) { try { @@ -254,7 +330,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) { + if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0)) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 9fc4256..7ef7ccb 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -27,6 +27,92 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + // 特殊处理 emby 源 + if (sourceCode === 'emby') { + try { + const config = await getConfig(); + const embyConfig = config.EmbyConfig; + + if (!embyConfig || !embyConfig.Enabled || !embyConfig.ServerURL) { + throw new Error('Emby 未配置或未启用'); + } + + const { EmbyClient } = await import('@/lib/emby.client'); + const client = new EmbyClient(embyConfig); + + // 获取媒体详情 + const item = await client.getItem(id); + + // 根据类型处理 + if (item.Type === 'Movie') { + // 电影 + const subtitles = client.getSubtitles(item); + + const result = { + source: 'emby', + source_name: 'Emby', + id: item.Id, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + douban_id: 0, + desc: item.Overview || '', + episodes: [client.getStreamUrl(item.Id)], + episodes_titles: [item.Name], + subtitles: subtitles.length > 0 ? [subtitles] : [], + proxyMode: false, + }; + + return NextResponse.json(result); + } else if (item.Type === 'Series') { + // 剧集 - 获取所有季和集 + const seasons = await client.getSeasons(item.Id); + const allEpisodes: any[] = []; + + for (const season of seasons) { + const episodes = await client.getEpisodes(item.Id, season.Id); + allEpisodes.push(...episodes); + } + + // 按季和集排序 + allEpisodes.sort((a, b) => { + if (a.ParentIndexNumber !== b.ParentIndexNumber) { + return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0); + } + return (a.IndexNumber || 0) - (b.IndexNumber || 0); + }); + + const result = { + source: 'emby', + source_name: 'Emby', + id: item.Id, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + year: item.ProductionYear?.toString() || '', + douban_id: 0, + desc: item.Overview || '', + episodes: allEpisodes.map((ep) => client.getStreamUrl(ep.Id)), + episodes_titles: allEpisodes.map((ep) => { + const seasonNum = ep.ParentIndexNumber || 1; + const episodeNum = ep.IndexNumber || 1; + return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + }), + subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), + proxyMode: false, + }; + + return NextResponse.json(result); + } else { + throw new Error('不支持的媒体类型'); + } + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + // 特殊处理 openlist 源 - 直接调用 /api/detail if (sourceCode === 'openlist') { try { diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index ec52bc2..c7c9e39 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1328,8 +1328,8 @@ function PlayPageClient() { !detailData.episodes || episodeIndex >= detailData.episodes.length ) { - // openlist 源的剧集是懒加载的,如果 episodes 为空则跳过 - if (detailData?.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) { + // openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过 + if ((detailData?.source === 'openlist' || detailData?.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) { return; } setVideoUrl(''); @@ -2207,8 +2207,9 @@ function PlayPageClient() { ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() : true) && (searchType - ? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表 + ? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表 result.source === 'openlist' || + result.source === 'emby' || (searchType === 'tv' && result.episodes.length > 1) || (searchType === 'movie' && result.episodes.length === 1) : true) @@ -2241,8 +2242,9 @@ function PlayPageClient() { ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() : true) && (searchType - ? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表 + ? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表 result.source === 'openlist' || + result.source === 'emby' || (searchType === 'tv' && result.episodes.length > 1) || (searchType === 'movie' && result.episodes.length === 1) : true) @@ -2327,9 +2329,9 @@ function PlayPageClient() { if (target) { detailData = target; - // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 - if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) { - console.log('[Play] OpenList source has no episodes, fetching detail...'); + // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息 + if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) { + console.log('[Play] OpenList/Emby source has no episodes, fetching detail...'); const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); if (detailSources.length > 0) { detailData = detailSources[0]; @@ -2350,14 +2352,25 @@ function PlayPageClient() { setLoadingStage('preferring'); setLoadingMessage('⚡ 正在优选最佳播放源...'); - detailData = await preferBestSource(sourcesInfo); + // 过滤掉 openlist 和 emby 源,它们不参与测速 + const sourcesToTest = sourcesInfo.filter(s => s.source !== 'openlist' && s.source !== 'emby'); + const excludedSources = sourcesInfo.filter(s => s.source === 'openlist' || s.source === 'emby'); + + if (sourcesToTest.length > 0) { + detailData = await preferBestSource(sourcesToTest); + } else if (excludedSources.length > 0) { + // 如果只有 openlist/emby 源,直接使用第一个 + detailData = excludedSources[0]; + } else { + detailData = sourcesInfo[0]; + } } console.log(detailData.source, detailData.id); - // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 - if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) { - console.log('[Play] OpenList source has no episodes after selection, fetching detail...'); + // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息 + if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) { + console.log('[Play] OpenList/Emby source has no episodes after selection, fetching detail...'); const detailSources = await fetchSourceDetail(detailData.source, detailData.id, detailData.title || videoTitleRef.current); if (detailSources.length > 0) { detailData = detailSources[0]; @@ -2498,6 +2511,68 @@ function PlayPageClient() { } }, [searchParams, currentSource, currentId, availableSources, currentEpisodeIndex]); + // 监听 detail 和 currentEpisodeIndex 变化,动态更新字幕 + useEffect(() => { + if (!artPlayerRef.current || !detail) return; + + const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || []; + const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; + + // 如果有字幕,更新播放器字幕 + if (currentSubtitles.length > 0) { + artPlayerRef.current.subtitle.switch(currentSubtitles[0].url, { + type: 'vtt', + style: { + color: '#fff', + fontSize: savedSubtitleSize, + }, + encoding: 'utf-8', + }); + + // 移除旧的字幕设置,添加新的 + try { + artPlayerRef.current.setting.remove('subtitle-selector'); + } catch (e) { + // 忽略错误,可能设置项不存在 + } + + const subtitleOptions = [ + { html: '关闭', url: '' }, + ...currentSubtitles.map((sub: any) => ({ + html: sub.label, + url: sub.url, + })), + ]; + + artPlayerRef.current.setting.add({ + name: 'subtitle-selector', + html: '字幕', + selector: subtitleOptions, + onSelect: function (item: any) { + if (artPlayerRef.current) { + if (item.url === '') { + artPlayerRef.current.subtitle.show = false; + } else { + artPlayerRef.current.subtitle.switch(item.url, { + name: item.html, + }); + artPlayerRef.current.subtitle.show = true; + } + } + return item.html; + }, + }); + } else { + // 没有字幕时,隐藏字幕并移除字幕设置 + artPlayerRef.current.subtitle.show = false; + try { + artPlayerRef.current.setting.remove('subtitle-selector'); + } catch (e) { + // 忽略错误,可能设置项不存在 + } + } + }, [detail, currentEpisodeIndex]); + // 处理换源 const handleSourceChange = async ( newSource: string, @@ -2548,8 +2623,8 @@ function PlayPageClient() { return; } - // 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息 - if (newDetail.source === 'openlist' && (!newDetail.episodes || newDetail.episodes.length === 0)) { + // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息 + if ((newDetail.source === 'openlist' || newDetail.source === 'emby') && (!newDetail.episodes || newDetail.episodes.length === 0)) { try { const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`); if (detailResponse.ok) { @@ -3479,8 +3554,8 @@ function PlayPageClient() { return; } - // openlist 源的剧集是懒加载的,如果 episodes 为空则跳过检查 - if ((currentSource === 'openlist' || detail?.source === 'openlist') && (!detail || !detail.episodes || detail.episodes.length === 0)) { + // openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过检查 + if ((currentSource === 'openlist' || currentSource === 'emby' || detail?.source === 'openlist' || detail?.source === 'emby') && (!detail || !detail.episodes || detail.episodes.length === 0)) { return; } @@ -3595,6 +3670,10 @@ function PlayPageClient() { Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; Artplayer.USE_RAF = true; + // 获取当前集的字幕 + const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || []; + const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; + artPlayerRef.current = new Artplayer({ container: artRef.current!, url: videoUrl, @@ -3614,6 +3693,17 @@ function PlayPageClient() { aspectRatio: false, fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器 fullscreenWeb: true, // 保留网页全屏按钮(所有平台) + ...(currentSubtitles.length > 0 ? { + subtitle: { + url: currentSubtitles[0].url, + type: 'vtt', + style: { + color: '#fff', + fontSize: savedSubtitleSize, + }, + encoding: 'utf-8', + } + } : {}), subtitleOffset: false, miniProgressBar: false, mutex: true, @@ -4532,6 +4622,70 @@ function PlayPageClient() { setPlayerReady(true); console.log('[PlayPage] Player ready, triggering sync setup'); + // 添加字幕切换功能 + const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || []; + if (currentSubtitles.length > 0 && artPlayerRef.current) { + const subtitleOptions = [ + { + html: '关闭', + url: '', + }, + ...currentSubtitles.map((sub: any) => ({ + html: sub.label, + url: sub.url, + })), + ]; + + artPlayerRef.current.setting.add({ + html: '字幕', + selector: subtitleOptions, + onSelect: function (item: any) { + if (artPlayerRef.current) { + if (item.url === '') { + // 关闭字幕 + artPlayerRef.current.subtitle.show = false; + } else { + // 切换字幕 + artPlayerRef.current.subtitle.switch(item.url, { + name: item.html, + }); + artPlayerRef.current.subtitle.show = true; + } + } + return item.html; + }, + }); + } + + // 添加字幕大小设置 + if (artPlayerRef.current) { + const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; + const defaultOption = savedSubtitleSize === '1em' ? '小' : savedSubtitleSize === '3em' ? '大' : savedSubtitleSize === '4em' ? '超大' : '中'; + + artPlayerRef.current.setting.add({ + html: '字幕大小', + selector: [ + { html: '小', size: '1em' }, + { html: '中', size: '2em' }, + { html: '大', size: '3em' }, + { html: '超大', size: '4em' }, + ], + onSelect: function (item: any) { + if (artPlayerRef.current) { + artPlayerRef.current.subtitle.style({ + fontSize: item.size, + }); + // 保存到 localStorage + if (typeof window !== 'undefined') { + localStorage.setItem('subtitleSize', item.size); + } + } + return item.html; + }, + default: defaultOption, + }); + } + // 控制截图按钮在小屏幕竖屏时隐藏 const updateScreenshotVisibility = () => { const screenshotBtn = document.querySelector('.art-control-screenshot') as HTMLElement; diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index cc7bd7b..746a966 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -5,23 +5,29 @@ import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import CapsuleSwitch from '@/components/CapsuleSwitch'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; +type LibrarySource = 'openlist' | 'emby'; + interface Video { id: string; - folder: string; - tmdbId: number; + folder?: string; + tmdbId?: number; title: string; poster: string; - releaseDate: string; - overview: string; - voteAverage: number; + releaseDate?: string; + year?: string; + overview?: string; + voteAverage?: number; + rating?: number; mediaType: 'movie' | 'tv'; } export default function PrivateLibraryPage() { const router = useRouter(); + const [source, setSource] = useState('openlist'); const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -29,16 +35,25 @@ export default function PrivateLibraryPage() { const [totalPages, setTotalPages] = useState(1); const pageSize = 20; + // 切换源时重置页码 + useEffect(() => { + setPage(1); + }, [source]); + useEffect(() => { fetchVideos(); - }, [page]); + }, [page, source]); const fetchVideos = async () => { try { setLoading(true); - const response = await fetch( - `/api/openlist/list?page=${page}&pageSize=${pageSize}` - ); + setError(''); + + const endpoint = source === 'openlist' + ? `/api/openlist/list?page=${page}&pageSize=${pageSize}` + : `/api/emby/list?page=${page}&pageSize=${pageSize}`; + + const response = await fetch(endpoint); if (!response.ok) { throw new Error('获取视频列表失败'); @@ -63,8 +78,8 @@ export default function PrivateLibraryPage() { }; const handleVideoClick = (video: Video) => { - // 跳转到播放页面,使用 id(key)而不是 folder - router.push(`/play?source=openlist&id=${encodeURIComponent(video.id)}`); + // 跳转到播放页面 + router.push(`/play?source=${source}&id=${encodeURIComponent(video.id)}`); }; return ( @@ -79,6 +94,18 @@ export default function PrivateLibraryPage() {

+ {/* 源切换器 */} +
+ setSource(value as LibrarySource)} + /> +
+ {error && (

{error}

@@ -97,7 +124,9 @@ export default function PrivateLibraryPage() { ) : videos.length === 0 ? (

- 暂无视频,请在管理面板配置 OpenList 并刷新 + {source === 'openlist' + ? '暂无视频,请在管理面板配置 OpenList 并刷新' + : '暂无视频,请在管理面板配置 Emby'}

) : ( @@ -107,12 +136,14 @@ export default function PrivateLibraryPage() { 0 + video.rating + ? video.rating.toFixed(1) + : video.voteAverage && video.voteAverage > 0 ? video.voteAverage.toFixed(1) : '' } diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 1a91dad..d283d7d 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -270,7 +270,8 @@ const EpisodeSelector: React.FC = ({ !optimizationEnabled || // 若关闭测速则直接退出 activeTab !== 'sources' || availableSources.length === 0 || - currentSource === 'openlist' // 私人影库不进行测速 + currentSource === 'openlist' || // 私人影库不进行测速 + currentSource === 'emby' // Emby 不进行测速 ) return; @@ -305,7 +306,7 @@ const EpisodeSelector: React.FC = ({ // 当后台加载从 true 变为 false 时(即加载完成) if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) { // 如果当前选项卡在换源位置,触发测速 - if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist') { + if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') { // 筛选出尚未测速的播放源 const pendingSources = availableSources.filter((source) => { const sourceKey = `${source.source}-${source.id}`; @@ -888,8 +889,8 @@ const EpisodeSelector: React.FC = ({
{/* 重新测试按钮 */} {(() => { - // 私人影库不显示重新测试按钮 - if (source.source === 'openlist') { + // 私人影库和 Emby 不显示重新测试按钮 + if (source.source === 'openlist' || source.source === 'emby') { return null; } diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index e0653ae..dbf9903 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -155,6 +155,17 @@ export interface AdminConfig { MaxTokens?: number; // 最大回复token数,默认1000 SystemPrompt?: string; // 自定义系统提示词 }; + EmbyConfig?: { + Enabled: boolean; // 是否启用Emby媒体库功能 + ServerURL: string; // Emby服务器地址 + ApiKey?: string; // API Key(推荐方式) + Username?: string; // 用户名(或使用API Key) + Password?: string; // 密码 + UserId?: string; // 用户ID(登录后获取) + Libraries?: string[]; // 要显示的媒体库ID(可选,默认全部) + LastSyncTime?: number; // 最后同步时间戳 + ItemCount?: number; // 媒体项数量 + }; } export interface AdminConfigResult { diff --git a/src/lib/emby.client.ts b/src/lib/emby.client.ts new file mode 100644 index 0000000..18d909b --- /dev/null +++ b/src/lib/emby.client.ts @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface EmbyConfig { + ServerURL: string; + ApiKey?: string; + Username?: string; + Password?: string; + UserId?: string; +} + +interface EmbyItem { + Id: string; + Name: string; + Type: 'Movie' | 'Series' | 'Season' | 'Episode'; + Overview?: string; + ProductionYear?: number; + CommunityRating?: number; + PremiereDate?: string; + ImageTags?: { Primary?: string }; + ParentIndexNumber?: number; + IndexNumber?: number; + MediaSources?: Array<{ + Id: string; + MediaStreams?: Array<{ + Type: string; + Index: number; + DisplayTitle?: string; + Language?: string; + Codec?: string; + IsExternal?: boolean; + DeliveryUrl?: string; + }>; + }>; +} + +interface EmbyItemsResult { + Items: EmbyItem[]; + TotalRecordCount: number; +} + +interface GetItemsParams { + ParentId?: string; + IncludeItemTypes?: string; + Recursive?: boolean; + Fields?: string; + SortBy?: string; + SortOrder?: string; + StartIndex?: number; + Limit?: number; + searchTerm?: string; +} + +export class EmbyClient { + private serverUrl: string; + private apiKey?: string; + private userId?: string; + private authToken?: string; + + constructor(config: EmbyConfig) { + let serverUrl = config.ServerURL.replace(/\/$/, ''); + // 如果 URL 不包含 /emby 路径,自动添加 + if (!serverUrl.endsWith('/emby')) { + serverUrl += '/emby'; + } + this.serverUrl = serverUrl; + this.apiKey = config.ApiKey; + this.userId = config.UserId; + } + + private getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.apiKey) { + headers['X-Emby-Token'] = this.apiKey; + } else if (this.authToken) { + headers['X-Emby-Token'] = this.authToken; + } + + return headers; + } + + async authenticate(username: string, password: string): Promise<{ AccessToken: string; User: { Id: string } }> { + const response = await fetch(`${this.serverUrl}/Users/AuthenticateByName`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Username: username, + Pw: password, + }), + }); + + if (!response.ok) { + throw new Error('Emby 认证失败'); + } + + const data = await response.json(); + this.authToken = data.AccessToken; + this.userId = data.User.Id; + return data; + } + + async getCurrentUser(): Promise<{ Id: string; Name: string }> { + const url = `${this.serverUrl}/Users/Me`; + const headers = this.getHeaders(); + + console.log('[EmbyClient] getCurrentUser - URL:', url); + console.log('[EmbyClient] getCurrentUser - Headers:', JSON.stringify(headers, null, 2)); + + try { + const response = await fetch(url, { headers }); + + console.log('[EmbyClient] getCurrentUser - Status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[EmbyClient] getCurrentUser - Error Response:', errorText); + throw new Error(`获取当前用户信息失败 (${response.status}): ${errorText}`); + } + + const data = await response.json(); + console.log('[EmbyClient] getCurrentUser - Success:', data); + return data; + } catch (error) { + console.error('[EmbyClient] getCurrentUser - Exception:', error); + throw error; + } + } + + async getItems(params: GetItemsParams): Promise { + if (!this.userId) { + throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置'); + } + + const searchParams = new URLSearchParams(); + + if (params.ParentId) searchParams.set('ParentId', params.ParentId); + if (params.IncludeItemTypes) searchParams.set('IncludeItemTypes', params.IncludeItemTypes); + if (params.Recursive !== undefined) searchParams.set('Recursive', params.Recursive.toString()); + if (params.Fields) searchParams.set('Fields', params.Fields); + if (params.SortBy) searchParams.set('SortBy', params.SortBy); + if (params.SortOrder) searchParams.set('SortOrder', params.SortOrder); + if (params.StartIndex !== undefined) searchParams.set('StartIndex', params.StartIndex.toString()); + if (params.Limit !== undefined) searchParams.set('Limit', params.Limit.toString()); + if (params.searchTerm) searchParams.set('searchTerm', params.searchTerm); + + // 添加认证参数 + const token = this.apiKey || this.authToken; + if (token) { + searchParams.set('X-Emby-Token', token); + } + + const url = `${this.serverUrl}/Users/${this.userId}/Items?${searchParams.toString()}`; + + console.log('[EmbyClient] getItems - URL:', url); + console.log('[EmbyClient] getItems - Token:', token); + console.log('[EmbyClient] getItems - UserId:', this.userId); + + const response = await fetch(url); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`获取 Emby 媒体列表失败 (${response.status}): ${errorText}`); + } + + return await response.json(); + } + + async getItem(itemId: string): Promise { + const token = this.apiKey || this.authToken; + const url = `${this.serverUrl}/Users/${this.userId}/Items/${itemId}?Fields=MediaSources${token ? `&api_key=${token}` : ''}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('获取 Emby 媒体详情失败'); + } + + return await response.json(); + } + + async getSeasons(seriesId: string): Promise { + const token = this.apiKey || this.authToken; + const url = `${this.serverUrl}/Shows/${seriesId}/Seasons?userId=${this.userId}${token ? `&api_key=${token}` : ''}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('获取 Emby 季列表失败'); + } + + const data = await response.json(); + return data.Items || []; + } + + async getEpisodes(seriesId: string, seasonId?: string): Promise { + const token = this.apiKey || this.authToken; + const searchParams = new URLSearchParams({ + userId: this.userId!, + Fields: 'MediaSources', + }); + + if (seasonId) { + searchParams.set('seasonId', seasonId); + } + + if (token) { + searchParams.set('api_key', token); + } + + const url = `${this.serverUrl}/Shows/${seriesId}/Episodes?${searchParams.toString()}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('获取 Emby 集列表失败'); + } + + const data = await response.json(); + return data.Items || []; + } + + async checkConnectivity(): Promise { + try { + const token = this.apiKey || this.authToken; + const url = `${this.serverUrl}/System/Info/Public${token ? `?api_key=${token}` : ''}`; + const response = await fetch(url); + return response.ok; + } catch { + return false; + } + } + + getImageUrl(itemId: string, imageType: 'Primary' | 'Backdrop' | 'Logo' = 'Primary', maxWidth?: number): string { + const params = new URLSearchParams(); + const token = this.apiKey || this.authToken; + + if (maxWidth) params.set('maxWidth', maxWidth.toString()); + if (token) params.set('api_key', token); + + const queryString = params.toString(); + return `${this.serverUrl}/Items/${itemId}/Images/${imageType}${queryString ? '?' + queryString : ''}`; + } + + getStreamUrl(itemId: string, direct: boolean = true): string { + if (direct) { + return `${this.serverUrl}/Videos/${itemId}/stream?Static=true&api_key=${this.apiKey || this.authToken}`; + } + return `${this.serverUrl}/Videos/${itemId}/master.m3u8?api_key=${this.apiKey || this.authToken}`; + } + + getSubtitles(item: EmbyItem): Array<{ url: string; language: string; label: string }> { + const subtitles: Array<{ url: string; language: string; label: string }> = []; + + if (!item.MediaSources || item.MediaSources.length === 0) { + return subtitles; + } + + const mediaSource = item.MediaSources[0]; + if (!mediaSource.MediaStreams) { + return subtitles; + } + + const token = this.apiKey || this.authToken; + + mediaSource.MediaStreams + .filter((stream) => stream.Type === 'Subtitle') + .forEach((stream) => { + const language = stream.Language || 'unknown'; + const label = stream.DisplayTitle || `${language} (${stream.Codec})`; + + // 外部字幕使用 DeliveryUrl + if (stream.IsExternal && stream.DeliveryUrl) { + subtitles.push({ + url: `${this.serverUrl}${stream.DeliveryUrl}`, + language, + label, + }); + } else { + // 内嵌字幕使用 Stream API + subtitles.push({ + url: `${this.serverUrl}/Videos/${item.Id}/${mediaSource.Id}/Subtitles/${stream.Index}/Stream.vtt?api_key=${token}`, + language, + label, + }); + } + }); + + return subtitles; + } +}