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
+ {/* 源切换器 */}
+
+ 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;
+ }
+}