从零构建 Emby 风格影视应用:零依赖前端 + 多源 API 聚合的实战心得
从零构建 Emby 风格影视应用:零依赖前端 + 多源 API 聚合的实战心得
本文记录了从需求分析到代码实现、再到华为云部署发布的完整过程。没有框架,没有构建工具,用最朴素的技术栈还原了 Emby 级别的影视浏览体验。
一、为什么选择"零依赖"
接到需求时,第一反应是上 Vue 或 React——毕竟有卡片列表、模态框、搜索过滤这些交互。但仔细想了想,这个应用的核心是"展示数据 + 播放视频",状态管理简单到用几个全局变量就够了。引入框架意味着:
- 需要构建工具(webpack/vite),增加部署复杂度
- 产物体积膨胀,首屏加载变慢
- 对于一个 3 页面应用,框架的收益远小于成本
最终选择 原生 HTML/CSS/JavaScript,整个前端只有一个 app.js(约 14KB)+ 一个 style.css(约 16KB)+ 一个 index.html(约 6KB),总计不到 40KB,首屏加载在毫秒级。
这不是"反框架"的主张,而是"匹配场景"的选择。如果你的应用有复杂的状态流转、组件复用需求,框架依然是正确答案。但对于这个项目,原生足够了。
二、架构设计:三层分离
┌─────────────────────────────────┐
│ 前端展示层 │
│ HTML/CSS/JS (Emby 深色主题) │
├─────────────────────────────────┤
│ 后端服务层 │
│ Express + RESTful API │
│ 图片代理 / 数据聚合 │
├─────────────────────────────────┤
│ 数据源层 │
│ sources.json (本地配置) │
│ 外部影视 API (远程数据) │
│ Emby/Jellyfin (原生格式) │
└─────────────────────────────────┘
后端只做三件事:管理 API 源配置、聚合多源视频数据、代理外部图片请求。前端只做两件事:渲染卡片和播放视频。数据层用 JSON 文件存储配置,无需数据库。
为什么后端需要存在?
一个纯前端应用直接调外部 API 不就行了?不行,原因有两个:
1. CORS 限制。 外部影视 API 几乎不会配置 Access-Control-Allow-Origin 允许你的域名访问。海报图片同理——浏览器会阻止跨域图片加载(虽然 <img> 标签本身不受 CORS 限制,但通过 fetch 获取图片数据时会)。后端代理是解决这个问题的标准方案。
2. API Key 安全。 如果在前端直接调用需要认证的 API,密钥会暴露在浏览器中。后端代理可以在服务端注入认证头,密钥不离开服务器。
三、关键技术实现
3.1 零依赖 HTTP 客户端
Node.js 18+ 虽然有全局 fetch,但为了兼容性和控制超时,我用 Node.js 原生的 http/https 模块封装了一个 fetchUrl 函数:
function fetchUrl(url, options = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const reqOptions = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: options.method || 'GET',
headers: options.headers || {},
timeout: 10000, // 10s 超时
};
const req = lib.request(reqOptions, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
if (options.body) req.write(options.body);
req.end();
});
}
不引入 axios 或 node-fetch,少一个依赖就少一个安全审计点。这个函数覆盖了 GET/POST 需求,10 秒超时防止挂起。
3.2 多源 API 聚合
这是应用的核心能力——用户可以添加多个影视 API 源,应用并行拉取并合并结果:
app.get('/api/videos', async (req, res) => {
const sources = readSources();
// 并行请求所有源
const fetchPromises = sources.map((s) => fetchVideosFromSource(s));
const results = await Promise.all(fetchPromises);
let videos = results.flat();
// 始终包含演示数据
const demoVideos = getSampleVideos();
videos = [...demoVideos, ...videos];
// 搜索/分类过滤...
res.json({ total: videos.length, videos });
});
Promise.all 并行请求所有源,任何一个源失败不影响其他源——fetchVideosFromSource 内部 try-catch 返回空数组而非抛出异常。这是优雅降级的思路:部分源不可用时,用户仍能看到其他源的内容和演示数据。
3.3 双格式适配器
影视 API 没有统一标准。最常见的两种格式:
通用格式——各种自建 API 常见的 JSON 结构:
{ "list": [{ "title": "星际穿越", "poster": "...", "stream_url": "..." }] }
Emby/Jellyfin 格式——Emby 原生 /Items 端点的响应:
{ "Items": [{ "Name": "星际穿越", "Id": "123", "ImageTags": { "Primary": "..." } }] }
两种格式的字段命名完全不同。解决方案是两个独立的解析函数,通过响应结构自动判断:
// 检测 Emby/Jellyfin 格式
if (data.Items && Array.isArray(data.Items)) {
return parseEmbyFormat(data, source);
}
return parseGenericFormat(data, source);
Emby 格式的图片 URL 需要拼接 API 基地址 + Item ID + Image Tag,而通用格式直接用 poster 字段。这种"自动检测 + 适配器模式"让用户不需要关心 API 源的格式——填上 format: 'auto' 即可。
3.4 CORS 代理:一行路由解决跨域
app.get('/api/proxy', async (req, res) => {
const targetUrl = req.query.url;
const result = await fetchUrl(targetUrl);
res.setHeader('Content-Type', result.headers['content-type']);
res.setHeader('Cache-Control', 'public, max-age=86400'); // 缓存一天
res.send(result.body);
});
前端所有外部图片都通过 /api/proxy?url=xxx 中转。代理响应设置 24 小时缓存,重复请求不会再次访问外部服务器。这个设计简单但在实际使用中效果显著——首次加载后海报图片全部走浏览器缓存。
3.5 演示数据:空状态也是设计
很多应用在没有数据时显示一个空白页面或"暂无数据"四个字。我的做法不同——内置 16 部演示影视(星际穿越、盗梦空间、沙丘等),即使没有配置任何 API 源,用户打开应用也能看到完整的 Emby 风格界面。
这不是"假数据",而是"展示态"。用户看到漂亮的卡片网格后,自然会想去设置页面添加自己的 API 源来获取真实视频。如果一开始就是空页面,用户可能直接关掉了。
// 始终包含演示数据
const demoVideos = getSampleVideos();
videos = [...demoVideos, ...videos];
四、Emby 深色主题还原
Emby 的视觉设计有几个标志性元素:
| 元素 | 值 | 实现方式 |
|---|---|---|
| 背景色 | #101010 |
body { background: #101010 } |
| 卡片背景 | #1a1a1a |
.card { background: #1a1a1a } |
| 强调色 | #52b54b |
CSS 变量 --accent: #52b54b |
| 卡片悬停 | #2a2a2a + 缩放 |
transform: scale(1.05) |
| 海报比例 | 2:3 | aspect-ratio: 2/3 |
| 卡片网格 | 响应式 5-6 列 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) |
929 行 CSS 逐个还原了这些细节。几个值得说的点:
Hero Banner。 首页顶部有一个全宽的推荐位,背景图 + 渐变遮罩 + 标题/简介/播放按钮。渐变遮罩用 linear-gradient 从透明到 #101010,保证文字在任何背景图上可读。
卡片悬停效果。 鼠标悬停时卡片轻微放大(scale(1.05)),同时叠加一个半透明遮罩和播放按钮图标。这个交互在 Emby 和 Netflix 上都有,用 CSS transition 实现,零 JavaScript 开销。
响应式网格。 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) 一行搞定。桌面自动 5-6 列,平板 3-4 列,手机 2 列。不需要媒体查询。
五、华为云部署发布实战
这部分是纯实战经验,分享几个关键步骤和踩坑点。
5.1 CodeArts 沙箱与命名空间隔离
代码通过 CodeArts ACP 协议在沙箱中生成。沙箱使用 bwrap 实现命名空间隔离——这意味着沙箱内的文件系统与宿主不共享。从宿主访问沙箱内的文件需要通过 /proc/<pid>/root/ 路径。
# 沙箱内写入的文件,宿主通过 /proc 访问
SANDBOX_ROOT="/proc/675448/root"
ls "$SANDBOX_ROOT/root/job-envs/sandboxes/.../emby-media-app/"
这个隔离机制在发布流程中很重要——发布技能需要访问项目文件生成封面截图和详情包,但文件在沙箱内。解决方案是将文件复制到工作空间。
5.2 DevBridge 隧道:本地服务的外部访问
应用在沙箱内 8080 端口运行,但外部用户无法直接访问。DevBridge 隧道解决了这个问题:
# 创建隧道
TUNNEL_ID=$(db_create "emby-media-tunnel" "Emby影视隧道" 72)
# 配置端口(允许匿名访问)
db_port_create "$TUNNEL_ID" 8080 auto true
# 启动托管
db_host "$TUNNEL_ID"
# 获取公网 URL
TUNNEL_URL=$(db_tunnel_url "$TUNNEL_ID")
# → https://a7sg4hla-8080.devbridge-s2.hwtunnel.com
关键点:中继域名随集群变化,不能按文档拼接 URL,必须用 db_tunnel_url 从 host 进程日志中读取权威地址。
5.3 封面截图:字体门禁
发布作品需要封面图,封面通过 Playwright 截图生成。但 Playwright 截图中文需要 CJK 字体——沙箱默认没有安装。发布技能的 preflight.sh 脚本负责安装字体和 Chromium,但首次运行需要 5-10 分钟下载。
# 后台启动 preflight(下载 Chromium + CJK 字体)
nohup bash preflight.sh > /tmp/preflight.log 2>&1 &
# 轮询等待门禁标记
while [ ! -f /tmp/font-gate-ok ]; do sleep 15; done
门禁通过后才能截图。如果跳过这一步,截图中中文会显示为方块(豆腐块)。
5.4 STS 临时凭证:安全发布
发布作品需要华为云 IAM 凭证认证。直接使用永久 AK/SK 有安全风险——凭证泄露后无法快速失效。发布技能使用 IAM 自委托 + STS 临时凭证方案:
- 通过
hcloudCLI 获取账号的 Domain ID - 创建 IAM 自委托(
SELF_VERIFY),授予最小权限 - 生成 STS 临时凭证(有效期 900 秒)
- 凭证写入
/tmp/sts-creds.json,包含_refresh元数据支持 401 自动刷新
永久 AK/SK 留在本地不上传,只有临时凭证参与网络传输。即使临时凭证被截获,900 秒后自动失效。
5.5 GitCode OAuth 两阶段授权
推送代码到 GitCode 需要 OAuth 授权。流程分两步:
# 阶段 1:启动回调服务器,返回登录链接和二维码
node gitcode-oauth.ensure.mjs --start
# → #oauth=started session_id=session_1 login_url=https://acs.atomgit.com/s/xxx
# 用户在浏览器中授权...
# 阶段 2:轮询等待 token 落盘
node gitcode-oauth.ensure.mjs --wait session_1
# → #oauth=done
关键设计:脚本本地启动回调服务器,用户在任意浏览器中授权后,脚本自动收到 token。不需要在 agent 内嵌浏览器——这对无头环境特别重要。
六、性能数据
| 指标 | 数值 |
|---|---|
| 前端总大小 | ~36KB(HTML + CSS + JS,未压缩) |
| 后端依赖 | 1 个(express) |
| npm install 耗时 | 7 秒 |
| 首屏渲染 | <100ms(16 个演示卡片) |
| API 响应(/api/videos) | <5ms(纯演示数据) |
| 图片代理缓存 | 24 小时 |
七、总结与思考
这个项目让我重新思考了几个问题:
"够用就好"不是偷懒。 零依赖前端、JSON 文件存储、单文件后端——每个选择都有明确的理由。不是为了"简单"而简单,而是因为复杂度匹配场景。一个个人影视浏览应用不需要 Kubernetes、不需要微服务、不需要 Redis。
空状态是产品的一部分。 16 部演示数据不是"假数据",而是"产品展示态"。用户第一次打开应用时的体验决定了他们是否会继续使用。
代理模式解决跨域是最朴素的方案。 CORS 问题的根源是浏览器安全策略。后端代理绕过了浏览器,在服务端发起请求。没有花哨的 CORS 配置、没有 JSONP、没有浏览器插件——一个 Express 路由搞定。
发布流程的自动化程度决定了迭代效率。 从代码生成到作品发布,整个流程涉及 OAuth 授权、STS 凭证、封面截图、字体门禁、详情打包、活动选择、API 发布——每一步都有脚本封装。这不是"过度工程",而是"一次投入,长期受益"。
- 点赞
- 收藏
- 关注作者
评论(0)