Files
MoonTVPlus/EMBY_INTEGRATION_DESIGN.md
2026-01-03 01:04:38 +08:00

17 KiB
Raw Blame History

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 格式要求

{
  "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 数据映射

// 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. 搜索/列表

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. 详情(关键)

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. 播放链接构建

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 核心代码

// 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. 配置类型定义

// 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 客户端

// 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 集成

修改订阅接口

// 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 认证

// 方式 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 获取媒体列表

// GET /Users/{userId}/Items
const params = new URLSearchParams({
  ParentId: libraryId,
  IncludeItemTypes: 'Movie,Series',
  Recursive: 'true',
  Fields: 'Overview,ProductionYear',
  StartIndex: '0',
  Limit: '20'
});

6.3 获取播放链接

// 直接播放(无转码)
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 获取剧集

// 获取季列表
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-ProxyCapsuleSwitch 的组合,实现了:

统一接口:网页和 TVBOX 都使用 CMS API 格式 统一入口:私人影库页面集成 OpenList 和 Emby 代码复用:最大化复用现有组件和逻辑 扩展性强未来添加其他源Jellyfin、Plex只需实现 cms-proxy 用户体验好:无缝切换,流畅播放

这是一个优雅、高效、可扩展的设计方案!