增加emby支持

This commit is contained in:
mtvpls
2026-01-03 01:04:38 +08:00
parent d9dbf4ac83
commit ea636b2307
15 changed files with 2478 additions and 37 deletions

616
EMBY_INTEGRATION_DESIGN.md Normal file
View File

@@ -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<LibrarySource>('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 (
<div>
<h1></h1>
{/* 源切换器 */}
<CapsuleSwitch
options={[
{ label: 'OpenList', value: 'openlist' },
{ label: 'Emby', value: 'emby' }
]}
active={source}
onChange={(value) => setSource(value as LibrarySource)}
/>
{/* 视频网格 */}
<div className="grid">
{videos.map(video => (
<VideoCard key={video.id} {...video} source={source} />
))}
</div>
{/* 分页 */}
</div>
);
}
```
---
## 四、完整实现步骤
### 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<AuthResult>
async getLibraries(): Promise<Library[]>
async getItems(params: GetItemsParams): Promise<ItemsResult>
async getItem(itemId: string): Promise<EmbyItem>
async getSeasons(seriesId: string): Promise<Season[]>
async getEpisodes(seriesId: string, seasonId: string): Promise<Episode[]>
async checkConnectivity(): Promise<boolean>
}
```
### 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
**用户体验好**:无缝切换,流畅播放
这是一个**优雅、高效、可扩展**的设计方案!

287
EMBY_USAGE_GUIDE.md Normal file
View File

@@ -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. 前台集成
- ✅ 私人影库页面集成 CapsuleSwitchOpenList ↔ 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 媒体库吧!🎬

View File

@@ -3399,6 +3399,222 @@ const OpenListConfigComponent = ({
);
};
// Emby 媒体库配置组件
const EmbyConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
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 (
<div className='space-y-6'>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
onConfirm={alertModal.onConfirm}
/>
{/* 启用开关 */}
<div className='flex items-center justify-between'>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
Emby
</label>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* 服务器地址 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Emby
</label>
<input
type='text'
value={serverURL}
onChange={(e) => 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'
/>
</div>
{/* API Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Key
</label>
<input
type='password'
value={apiKey}
onChange={(e) => 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
使 API Key 使 API Key
</p>
</div>
{/* 用户名 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
value={username}
onChange={(e) => 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'
/>
</div>
{/* 密码 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
value={password}
onChange={(e) => 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'
/>
</div>
{/* 用户 ID */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
ID使 API Key
</label>
<input
type='text'
value={userId}
onChange={(e) => 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Emby ID URL /Users/[userId]/...
</p>
</div>
{/* 操作按钮 */}
<div className='flex gap-3'>
<button
onClick={handleTest}
disabled={isLoading('testEmby')}
className='px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
{isLoading('testEmby') ? '测试中...' : '测试连接'}
</button>
<button
onClick={handleSave}
disabled={isLoading('saveEmby')}
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg transition-colors'
>
{isLoading('saveEmby') ? '保存中...' : '保存配置'}
</button>
</div>
</div>
);
};
// 视频源配置组件
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() {
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* Emby 媒体库标签 */}
<CollapsibleTab
title='Emby 媒体库'
icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.embyConfig}
onToggle={() => toggleTab('embyConfig')}
>
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* AI配置标签 */}
<CollapsibleTab
title='AI设定'

View File

@@ -0,0 +1,184 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { EmbyClient } from '@/lib/emby.client';
export const runtime = 'nodejs';
/**
* POST /api/admin/emby
* 保存 Emby 配置
*/
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{ error: '不支持本地存储进行管理员配置' },
{ status: 400 }
);
}
try {
const body = await request.json();
const { action, Enabled, ServerURL, ApiKey, Username, Password, UserId, Libraries } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 获取配置
const adminConfig = await getConfig();
// 权限检查
if (username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(username);
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
if (action === 'save') {
// 如果功能未启用,允许保存空配置
if (!Enabled) {
adminConfig.EmbyConfig = {
Enabled: false,
ServerURL: ServerURL || '',
ApiKey: ApiKey || '',
Username: Username || '',
Password: Password || '',
Libraries: Libraries || [],
};
await db.saveAdminConfig(adminConfig);
return NextResponse.json({ success: true, message: 'Emby 配置已保存(未启用)' });
}
// 验证必填字段
if (!ServerURL) {
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
}
if (!ApiKey && (!Username || !Password)) {
return NextResponse.json(
{ error: '请填写 API Key 或用户名密码' },
{ status: 400 }
);
}
// 测试连接
const testConfig = {
ServerURL,
ApiKey,
Username,
Password,
UserId,
};
const client = new EmbyClient(testConfig);
// 如果使用用户名密码,先认证
let finalUserId: string | undefined = UserId; // 使用用户提供的 UserId
if (!ApiKey && Username && Password) {
try {
const authResult = await client.authenticate(Username, Password);
finalUserId = authResult.User.Id;
} catch (error) {
return NextResponse.json(
{ error: 'Emby 认证失败: ' + (error as Error).message },
{ status: 400 }
);
}
}
// <20><>试连接
const isConnected = await client.checkConnectivity();
if (!isConnected) {
return NextResponse.json(
{ error: 'Emby 连接失败,请检查服务器地址和认证信息' },
{ status: 400 }
);
}
// 保存配置
adminConfig.EmbyConfig = {
Enabled: true,
ServerURL,
ApiKey: ApiKey || undefined,
Username: Username || undefined,
Password: Password || undefined,
UserId: finalUserId,
Libraries: Libraries || [],
LastSyncTime: Date.now(),
};
await db.saveAdminConfig(adminConfig);
return NextResponse.json({
success: true,
message: 'Emby 配置已保存并测试成功',
});
}
if (action === 'test') {
// 测试连接
if (!ServerURL) {
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
}
if (!ApiKey && (!Username || !Password)) {
return NextResponse.json(
{ error: '请填写 API Key 或用户名密码' },
{ status: 400 }
);
}
const testConfig = {
ServerURL,
ApiKey,
Username,
Password,
};
const client = new EmbyClient(testConfig);
// 如果使用用户名密码,先认证
if (!ApiKey && Username && Password) {
try {
await client.authenticate(Username, Password);
} catch (error) {
return NextResponse.json(
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
{ status: 200 }
);
}
}
// 测试连接
const isConnected = await client.checkConnectivity();
if (!isConnected) {
return NextResponse.json(
{ success: false, message: 'Emby 连接失败,请检查服务器地址和认证信息' },
{ status: 200 }
);
}
return NextResponse.json({
success: true,
message: 'Emby 连接测试成功',
});
}
return NextResponse.json({ error: '不支持的操作' }, { status: 400 });
} catch (error) {
console.error('Emby 配置保存失败:', error);
return NextResponse.json(
{ error: 'Emby 配置保存失败: ' + (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,252 @@
/* 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';
/**
* Emby CMS 代理接口(动态路由)
* 将 Emby 媒体库转换为 TVBox 兼容的 CMS API 格式
* 路径格式:/api/emby/cms-proxy/{token}?ac=videolist&...
*/
export async function GET(
request: NextRequest,
{ params }: { params: { token: string } }
) {
const { searchParams } = new URL(request.url);
const ac = searchParams.get('ac');
const wd = searchParams.get('wd'); // 搜索关键词
const ids = searchParams.get('ids'); // 视频ID
// 检查必要参数
if (ac !== 'videolist' && ac !== 'list' && ac !== 'detail') {
return NextResponse.json(
{ code: 400, msg: '不支持的操作' },
{ status: 400 }
);
}
// 验证 TVBox Token
const requestToken = params.token;
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
if (!subscribeToken || requestToken !== subscribeToken) {
return NextResponse.json({
code: 401,
msg: '无效的访问token',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
try {
const config = await getConfig();
const embyConfig = config.EmbyConfig;
// 验证 Emby 配置
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
return NextResponse.json({
code: 0,
msg: 'Emby 未配置或未启用',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
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({
code: 0,
msg: 'Emby 认证失败',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
// 路由处理
if (wd) {
// 搜索模式
if (ac === 'detail') {
return await handleDetailBySearch(client, wd, requestToken, request);
}
return await handleSearch(client, wd);
} else if (ids || ac === 'detail') {
// 详情模式
if (!ids) {
return NextResponse.json({
code: 0,
msg: '缺少视频ID',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
return await handleDetail(client, ids, requestToken, request);
} else {
// 列表模式
return await handleSearch(client, '');
}
} catch (error) {
console.error('[Emby CMS Proxy] 错误:', error);
return NextResponse.json({
code: 500,
msg: (error as Error).message,
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
}
/**
* 处理搜索请求
*/
async function handleSearch(client: EmbyClient, query: string) {
const result = await client.getItems({
searchTerm: query || undefined,
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
Limit: 100,
});
const list = result.Items.map((item) => ({
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',
},
],
});
}

View File

@@ -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 }
);
}
}

View File

@@ -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,
});
}
}

View File

@@ -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<any>).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 || '';

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<LibrarySource>('openlist');
const [videos, setVideos] = useState<Video[]>([]);
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) => {
// 跳转到播放页面,使用 idkey而不是 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() {
</p>
</div>
{/* 源切换器 */}
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
{ label: 'OpenList', value: 'openlist' },
{ label: 'Emby', value: 'emby' }
]}
active={source}
onChange={(value) => setSource(value as LibrarySource)}
/>
</div>
{error && (
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
<p className='text-red-800 dark:text-red-200'>{error}</p>
@@ -97,7 +124,9 @@ export default function PrivateLibraryPage() {
) : videos.length === 0 ? (
<div className='text-center py-12'>
<p className='text-gray-500 dark:text-gray-400'>
OpenList
{source === 'openlist'
? '暂无视频,请在管理面板配置 OpenList 并刷新'
: '暂无视频,请在管理面板配置 Emby'}
</p>
</div>
) : (
@@ -107,12 +136,14 @@ export default function PrivateLibraryPage() {
<VideoCard
key={video.id}
id={video.id}
source='openlist'
source={source}
title={video.title}
poster={video.poster}
year={video.releaseDate.split('-')[0]}
year={video.year || (video.releaseDate ? video.releaseDate.split('-')[0] : '')}
rate={
video.voteAverage && video.voteAverage > 0
video.rating
? video.rating.toFixed(1)
: video.voteAverage && video.voteAverage > 0
? video.voteAverage.toFixed(1)
: ''
}

View File

@@ -270,7 +270,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0 ||
currentSource === 'openlist' // 私人影库不进行测速
currentSource === 'openlist' || // 私人影库不进行测速
currentSource === 'emby' // Emby 不进行测速
)
return;
@@ -305,7 +306,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 当后台加载从 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<EpisodeSelectorProps> = ({
</div>
{/* 重新测试按钮 */}
{(() => {
// 私人影库不显示重新测试按钮
if (source.source === 'openlist') {
// 私人影库和 Emby 不显示重新测试按钮
if (source.source === 'openlist' || source.source === 'emby') {
return null;
}

View File

@@ -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 {

289
src/lib/emby.client.ts Normal file
View File

@@ -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<string, string> {
const headers: Record<string, string> = {
'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<EmbyItemsResult> {
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<EmbyItem> {
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<EmbyItem[]> {
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<EmbyItem[]> {
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<boolean> {
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;
}
}