增加emby支持
This commit is contained in:
616
EMBY_INTEGRATION_DESIGN.md
Normal file
616
EMBY_INTEGRATION_DESIGN.md
Normal 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
287
EMBY_USAGE_GUIDE.md
Normal 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. 前台集成
|
||||
- ✅ 私人影库页面集成 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 媒体库吧!🎬
|
||||
@@ -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设定'
|
||||
|
||||
184
src/app/api/admin/emby/route.ts
Normal file
184
src/app/api/admin/emby/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
252
src/app/api/emby/cms-proxy/[token]/route.ts
Normal file
252
src/app/api/emby/cms-proxy/[token]/route.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
85
src/app/api/emby/detail/route.ts
Normal file
85
src/app/api/emby/detail/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
100
src/app/api/emby/list/route.ts
Normal file
100
src/app/api/emby/list/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
// 跳转到播放页面,使用 id(key)而不是 folder
|
||||
router.push(`/play?source=openlist&id=${encodeURIComponent(video.id)}`);
|
||||
// 跳转到播放页面
|
||||
router.push(`/play?source=${source}&id=${encodeURIComponent(video.id)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,6 +94,18 @@ export default function PrivateLibraryPage() {
|
||||
</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)
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
289
src/lib/emby.client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user