TryBox Stremio Addon 开发全记录
一篇从探针到生产部署的完整技术复盘。包含协议逆向、架构设计、缓存分层、ID 兼容、踩坑与技巧。既是本项目的技术沉淀,也可作为 Stremio Addon 开发的参考。
一、项目背景与目标
1.1 缘起
TryBox(trybox.app)是一个影视聚合站点,提供影视元数据、多源播放链接、中英文字幕。它没有公开 API,所有请求走 /_serverFn/ 下的加密端点,payload 是自定义的 t/p/k/v/a 结构。
目标:把 TryBox 的数据封装成 Stremio Addon,让用户能在 Stremio 客户端里浏览、搜索、看详情、加载字幕、播放流。同时开发一个独立的 Nuvio Provider,让 Nuvio 用户也能访问 TryBox 的流。
1.2 约束
| 约束 | 说明 |
|---|---|
| 国内访问 | 用户主要在国内,必须考虑网络可达性 |
| Vercel 部署 | 选择 Vercel 而非 Cloudflare Workers |
| Edge Runtime | 必须使用 Vercel Edge Function,零第三方运行时依赖 |
| Upstash 共享 | 多个项目共用一个 Upstash Redis,必须用 APP_PREFIX 隔离 |
| 成本 | 免费层额度内运行 |
| 规范 | 遵守已有的项目开发规范(配置分层、缓存 TTL、日志分级等) |
二、协议逆向:探针的设计与实施
2.1 为什么先写探针
TryBox 的 API 是自定义协议。直接写 addon 会面临:
- 不知道请求 payload 的确切结构
- 不知道响应的数据字段和类型
- 不知道 stream 链接是否带鉴权
- 不知道字幕接口的参数组合
探针的本质:一个独立的诊断脚本,**只做”发请求 + 打印结果”**,不参与业务逻辑。它的职责是:
- 验证 API 可用性
- 探明请求/响应结构
- 评估数据完整性和响应时间
- 为后续开发提供”事实依据”
探针原则:不猜、不想当然、以实际返回为准。
2.2 探针的整体设计
我实现的探针脚本(probe.js)覆盖 5 个核心接口:
| 探针 | 用途 | 关键发现 |
|---|---|---|
| catalog | 首页各板块数据 | 一次请求返回全部板块(713KB),可复用 |
| search | 关键词搜索 | 支持 multi / movie / tv 三种类型 |
| meta | 影片详情 | TV 类型只返回 seasons 列表,不含 episodes |
| subtitle | 字幕列表 | 需要 imdb_id,支持语言过滤 |
| stream | 播放链接 | POST 请求(不是 GET),需要 Bearer Token |
2.3 探针的核心技术:decodeNode
TryBox 的响应是加密的 t/p/k/v/a 结构,需要解码:
1 | function decodeNode(node) { |
关键洞察:
t: 0表示数字,t: 1表示字符串,值在s字段t: 10表示对象,键在p.k,值在p.v,需要递归解码t: 9表示数组,元素在a
这个解码器是后续所有 handler 的基础。
2.4 探针的三大产出
产出 1:请求 payload 模板
1 | // Catalog 请求 |
产出 2:响应结构地图
| 接口 | 顶层字段 | 数组板块 |
|---|---|---|
| Catalog | trending, popular, topRated, secondary, tertiary, presets.* |
12 个数组 |
| Meta | id, imdb_id, seasons, cast, videos, … |
5 个数组 |
| Stream | meta, links, mbp_links, beta_links |
3 个数组 |
产出 3:关键事实
- Catalog 一次请求返回全部板块(713KB),所有 catalog 可共享同一份缓存
- Meta 的 TV 类型需要额外请求 episodes 接口(每季一次)
- Stream 是 POST 请求,必须带
Authorization: Bearerheader - Stream 链接部分带
IP查询参数(IP 校验),部分不带 - 字幕接口用
imdb_id,不用tmdb_id
2.5 探针的补充:episodes 探针
第一轮探针没覆盖 episodes(剧集列表),导致后面写 meta 时遇到问题。补充探针后确认:
- episodes 接口独立于 meta
- 每季一次请求(不能一次拿全部季)
- 响应包含
episode_number、name、air_date、still_path等
教训:探针要覆盖所有下游接口,不能只测主接口。
2.6 探针的最大价值
**避免”想当然”**。举例:
- 如果没跑探针,会以为 stream 是 GET 请求 → 405 错误
- 如果没跑探针,会以为 catalog 每个板块需要单独请求 → 浪费 11 倍请求
- 如果没跑探针,会以为 stream 链接都能用 → 用户播放失败率极高
- 如果没跑探针,会以为 subtitle 用 tmdb_id → 永远查不到字幕
探针是事实来源,代码是对事实的翻译。
三、架构设计
3.1 分层架构
1 | ┌─────────────────────────────────────────────────────┐ |
3.2 模块职责
| 模块 | 职责 | 是否纯函数 |
|---|---|---|
decode.js |
TryBox 协议解码 | 是 |
payload.js |
请求 payload 构建 | 是 |
headers.js |
请求头构建(API / Stream 分离) | 是 |
http.js |
HTTP 客户端(4xx 不重试、5xx 重试) | 否(有网络) |
cache.js |
三级缓存 + 冷却降级 | 否 |
token.js |
Token 三级刷新 + Bark 告警 | 否 |
tmdb.js |
tt → tmdb 转换 | 否 |
config.js |
配置合并(默认 + env + user) | 是 |
logger.js |
分级日志 | 是 |
manifest.js |
Manifest 动态生成 | 是 |
handler/*.js |
各资源处理逻辑 | 否 |
3.3 关键设计决策
决策 1:为什么用 trybox_{tmdbId} 而不是 tt{imdbId}
- Catalog 返回的 id 必须是 TryBox 能直接用的 id
- TryBox 的 catalog 只返回 TMDB ID
- 如果 catalog 用 tt ID,就需要把 125 个 TMDB ID 都转成 IMDb ID(要么批量查 TMDB,要么逐条查 meta,都很慢)
- 保留
trybox_前缀做内部链路,额外支持tt做外部联动,两全其美
决策 2:为什么单独设计 token 管理
- TryBox 的 stream 接口需要 Bearer Token
- Token 来自 Supabase,refresh_token 是一次性的(用一次就失效)
- 必须持久化(内存 + Redis),否则每次请求都拿旧 token → 报
refresh_token_already_used - 三级刷新:内存 → 用户配置 → Redis → refresh 刷新 → access 刷新 → Bark 告警
决策 3:为什么 Token 要同时存内存和 Redis
- 内存:同一 isolate 内快速复用,省 Upstash 请求
- Redis:跨 isolate、跨请求持久化
- 写入时同时写两份,读取时先查内存、再查 Redis
- **access_token 检查 JWT
exp**,未过期直接用,避免浪费 refresh_token
四、ID 兼容与联动:Stremio 生态的关键技巧
4.1 Stremio 的 ID 机制
Stremio 的资源请求格式是 /{resource}/{type}/{id}.json。
请求链:
1 | Catalog 返回 id → Stremio 用这个 id 请求 Meta → Stremio 用 Meta 的 id 请求 Stream |
idPrefixes 的作用:manifest 中声明 idPrefixes 后,Stremio 只会把匹配前缀的请求发给你的 addon。
1 | resources: [ |
关键规则:
idPrefixes对 catalog 无效(catalog 是特例)- 同一个资源可以声明多个前缀
- 不同资源可以有不同的
idPrefixes
4.2 双 ID 兼容的实现
链路 1:站内 ID(trybox_)
1 | Catalog → 返回 trybox_76331 |
链路 2:外部 IMDb ID(tt)
1 | Cinemeta → 返回 tt7660850 |
统一入口:parseAnyId(env, id, type, tmdbKey)
1 | export async function parseAnyId(env, id, type, tmdbKey) { |
4.3 联动效果
当用户从 Cinemeta 进入播放页面时,Stremio 会用 tt7660850 请求所有支持 tt 前缀的 addon。你的 addon 也是其中之一。用户能看到你的流,和其他 addon 的流一起展示。
这就实现了 addon 生态的联动。
4.4 tt → tmdb 转换的缓存
转换结果缓存 7 天(规范里 IMDb 映射的 TTL):
1 | export async function resolveImdbId(env, ttId, tmdbKey) { |
收益:第二次请求同一个 tt ID 时,0 次 TMDB 请求。
五、缓存分层:三级缓存的工程实践
5.1 缓存分层设计
| 层级 | 存储 | 生命周期 | 命中耗时 |
|---|---|---|---|
| L1 | 内存 Map | isolate 生命周期 | <1ms |
| L2 | Upstash Redis | TTL 控制 | 200-1500ms |
| CDN | Vercel 边缘 | TTL 控制 | 100-400ms |
缓存 key 结构:{APP_PREFIX}:cache:{业务key}
统一的 withCache 包装器:
1 | export async function withCache({ env, key, l1Ttl, redisTtl, fetcher, shouldCache }) { |
5.2 各资源的缓存 TTL
| 数据 | L1 | Redis | CDN |
|---|---|---|---|
| Catalog | 60s | 5 分钟 | 5 分钟 |
| Meta | 60s | 30 分钟 | 30 分钟 |
| Episodes | 60s | 30 分钟 | — |
| Search | 60s | 10 分钟 | 5 分钟 |
| Subtitle | 60s | 10 分钟 | 5 分钟 |
| Stream(成功) | 60s | 3 分钟 | 1 分钟 |
| Stream(负面) | — | 5 分钟 | — |
| IMDb 映射 | 1 天 | 7 天 | — |
5.3 缓存 key 设计
关键决策:
- **Catalog 用同一个 key
catalog:home**:因为 TryBox 一次返回全部板块,所有 catalog 共享 - **Episodes 用单个 key
episodes:{tvId}**:包含全部季,而不是每季一个 key - **tt 转换用
imdb_to_tmdb:{ttId}**:全站共享
收益对比(Meta series):
| 方案 | 冷启动请求 | 热启动请求 |
|---|---|---|
| 每季一个 key | 1 次 meta + N 次 episodes + 2(N+1) 次 Upstash | 1 + N 次 Upstash |
| 单个 key | 1 次 meta + N 次 episodes + 4 次 Upstash | 2 次 Upstash |
继承之战 4 季:从 5 次 Upstash 降到 2 次。
5.4 Redis 冷却降级
问题:Redis 挂掉时,每次请求都等超时,用户体验极差。
方案:连续 3 次失败进入 60 秒冷却,期间只用 L1:
1 | let redisFailCount = 0; |
5.5 Upstash REST API 的正确用法
踩坑:我一开始用 URL 路径拼接多参数命令:
1 | // ❌ 错误 |
Upstash 不识别这种格式,会静默失败(返回空值,但 HTTP 200)。
正确做法:用 /pipeline 端点,发送 JSON 数组:
1 | // ✅ 正确 |
教训:第三方 API 必须核对官方文档的调用格式,不能凭直觉拼 URL。
5.6 生产环境 CDN 缓存
Vercel Edge Function 的响应可以加 Cache-Control 头,让 Vercel CDN 缓存:
1 | function jsonResponse(data, status = 200, { cacheControl } = {}) { |
效果:
- 第一次请求:
x-vercel-cache: MISS - 第二次请求:
x-vercel-cache: HIT(不触发 Edge Function)
注意:Vercel 会把响应头的 Cache-Control 重写为 public,但内部的 s-maxage 仍然生效。
六、Token 管理的完整方案
6.1 需求分析
TryBox 的 stream 接口需要 Bearer Token:
- Token 来自 Supabase Auth
refresh_token是一次性的(用一次就返回新的,旧的立即失效)access_token有效期 1 小时- 必须持久化,否则每次请求都拿旧 token 撞
already_used - 失效时要告警给管理员
6.2 三级刷新流程
1 | 请求 |
关键设计:
- **先检查 JWT
exp**,未过期就用,避免浪费 refresh_token - 同时写内存和 Redis,跨请求持久化
- Bark 告警:token 全失效时通知管理员
6.3 JWT 过期检查
access_token 是 JWT,可以 base64 解码 payload 拿 exp:
1 | function decodeJwtExpiry(token) { |
6.4 环境隔离
用 APP_PREFIX 区分环境:
| 环境 | APP_PREFIX |
|---|---|
| Production | tryboxStream:prod |
| Preview | tryboxStream:preview |
| Development | tryboxStream:dev |
收益:本地/预览的脏数据不会污染生产缓存。
七、部署与运维
7.1 Vercel 部署
1 | npx vercel --prod |
环境变量(Vercel Dashboard):
| 变量 | 必填 |
|---|---|
UPSTASH_REDIS_REST_URL |
是 |
UPSTASH_REDIS_REST_TOKEN |
是 |
APP_PREFIX |
是 |
BARK_KEY |
否 |
TMDB_KEY |
否 |
LOG_LEVEL |
否 |
注意:*.vercel.app 在国内访问不稳定,必须绑定自定义域名。
7.2 国内访问优化
问题:*.vercel.app 国内访问延迟 1-4 秒。
方案:
- 绑定自定义域名(必须)
- 优选 CNAME:
vercel-cname.xingpingcn.top(社区维护的优选 IP) - Cloudflare 代理不推荐(会剥掉
x-vercel-cache头)
效果:国内延迟从 1-4 秒降到 200-500ms。
7.3 health.js 健康检测
34 项测试,覆盖:
| 分组 | 测试项 | 说明 |
|---|---|---|
| manifest | 5 | 默认配置、资源完整性、功能开关 |
| catalog | 10 | 8 个 catalog × 各自的 type |
| search | 3 | 中文关键词 |
| genre | 4 | movie/series × 各 genre |
| meta | 2 | movie + series |
| subtitle | 2 | movie + series |
| stream | 2 | movie + series |
| tt-id | 5 | tt 格式 + manifest 声明 |
| debug | 1 | 健康端点 |
关键设计:
- 上游波动不判失败:
no_valid_url视为 warning(TryBox 上游偶发返回空) - 真实失败才判失败:
token_failed、http_failed、decode_failed - 支持跳过慢测试:
SKIP_SLOW=1
7.4 日志系统
分级:debug / info / warn / error
格式:[HH:mm:ss.SSS][模块][级别] 消息
示例:
1 | [22:54:08.157][TMDB] [INFO] TMDB find: tt0109830 |
模块前缀:Edge / Router / Catalog / Meta / Subtitle / Stream / Token / Cache / TMDB
八、踩坑与技巧
8.1 探针阶段
坑 1:以为 stream 是 GET
- 实际是 POST,且必须带
Content-Type: application/json+ Bearer Token - 教训:探针必须打印实际请求方法
坑 2:以为 catalog 每个板块单独请求
- 实际一次请求返回全部(713KB)
- 教训:探针要打印响应的完整结构,不能只看长度
坑 3:以为 meta 就够用
- TV 类型需要额外请求 episodes
- 教训:探针要覆盖所有下游接口
8.2 开发阶段
坑 4:idPrefixes 混用
Stremio 允许顶层声明和资源级声明两种方式。资源级覆盖顶层。
正确配置:
1 | resources: [ |
坑 5:vercel dev 和生产的差异
| 差异 | 本地 vercel dev |
生产 Vercel |
|---|---|---|
| L1 缓存 | 每次新 isolate | 热 isolate 保留 |
| CDN 缓存 | 不生效 | 生效 |
fetch 走代理 |
不走系统代理 | 不需要(直连) |
| Cache-Control | 不重写 | 重写为 public |
教训:本地能跑 ≠ 生产能跑。关键功能必须在生产验证。
坑 6:Upstash URL 路径拼接多参数命令失败
SET key value EX 3600 用 URL 路径拼静默失败。**必须用 /pipeline**。
坑 7:诊断 stream 的可读性
错误做法:诊断信息放 title 字段,会被 Stremio 截断。
正确做法:诊断信息放 url 字段,中文原文不编码,用户长按可复制。
1 | // ✅ 正确 |
8.3 技巧与妙用
技巧 1:用 bingeGroup 提升剧集体验
1 | { |
Stremio 会记住用户在同一 bingeGroup 里选择的质量,下一集自动匹配相同质量。
技巧 2:stremio:// 协议安装
配置页面的安装按钮用 stremio:// 协议:
1 | window.location.href = 'stremio://' + url.replace(/^https?:\/\//, ''); |
比让用户手动复制粘贴 URL 更友好。
技巧 3:Catalog 支持 Search + Genre
不需要单独的 search handler。在 catalog 的 extra 里声明:
1 | extra: [ |
Catalog handler 里根据 extra.search 和 extra.genre 分流。
技巧 4:Negative cache 避免反复回源
上游返回空结果时,缓存 5 分钟:
1 | if (!validLinks.length) { |
收益:同一失败请求在 5 分钟内不再回源。
技巧 5:?cfg= 纯前端编码
配置参数用 base64url 编码的 JSON 传递,addon 后端直接解码,无需服务端参与:
1 | // 前端 |
技巧 6:Stremio 客户端缓存
Stremio 客户端会缓存 catalog 和 meta 数据。如果你在开发时改了代码,客户端可能仍显示旧数据。解决办法:
- 重启 Stremio 客户端
- 清除缓存(设置里)
- 换一个新的 manifest URL(加随机 query 参数)
九、项目总结与展望
9.1 关键成果
| 指标 | 结果 |
|---|---|
| 本地 health.js | 34/34 通过 |
| 生产 health.js | 34/34 通过 |
| Catalog 缓存命中 | ~430ms(本地 1.3s) |
| Meta series 缓存命中 | ~800ms(原 7.9s) |
| tt 路径 | 完整支持 movie + series + subtitle |
| CDN 缓存 | x-vercel-cache: HIT |
| 第三方依赖 | 零(纯 Web 标准 API) |
9.2 架构亮点
- 三级缓存:L1 内存 + Upstash Redis + Vercel CDN,每层独立降级
- 双 ID 兼容:
trybox_站内链路 +tt外部联动,统一入口parseAnyId - Token 三级刷新:内存 + 用户配置 + Redis,滚动刷新 + Bark 告警
- Episodes 单 key 合并:从 N 次 Upstash 请求降到 1 次
- Negative cache:上游失败不反复回源
9.3 后续优化方向
| 方向 | 说明 |
|---|---|
| Nuvio Provider | 独立项目,Promise 链式写法,不剔除 IP 校验链接 |
| Catalog 按 section 拆分 | 单 catalog 用户拉取量从 713KB 降到 60KB |
| TMDB 备用 API | 主 API 失败时切换备用(如 api.tmdb.org 镜像) |
| BingeGroup 优化 | 剧集播放时自动匹配质量 |
| 自定义域名 | 国内访问优化(优选 CNAME) |
| 监控告警 | Vercel 用量告警 + Bark 推送 |
9.4 经验教训
关于探针:
- 探针是事实来源,不是可选步骤
- 探针要覆盖所有下游接口
- 探针的输出要保留(作为后续开发的参考)
关于协议:
- 第三方 API 必须核对官方文档
- 不能凭直觉拼 URL / 猜请求方法
关于缓存:
- 缓存分层要明确 TTL
- 缓存 key 要统一前缀
- Redis 挂掉要降级
关于 Stremio:
idPrefixes是资源级配置,不是全局- Catalog 是特例,
idPrefixes无效 - 诊断信息放
url,不放title bingeGroup提升剧集体验
关于部署:
- 本地能跑 ≠ 生产能跑
*.vercel.app国内访问不稳定- 关键功能必须在生产验证
十、附录:Stremio Addon 开发注意事项清单
10.1 Manifest 规范
-
id必须全局唯一,建议用反向域名(如community.trybox) -
version语义化版本 -
resources用对象数组声明,每个资源单独配types和idPrefixes -
catalogs里的extra声明 search / genre / skip -
idPrefixes不能包含冒号(Stremio 用:分隔 season/episode) -
behaviorHints.configurable: true开启配置页
10.2 Catalog 规范
-
metas数组里的每一项必须有id、type、name -
poster用https://image.tmdb.org/t/p/w500{path} -
background用https://image.tmdb.org/t/p/original{path} -
releaseInfo建议只放年份 -
imdbRating是字符串(不是数字) - 一个 catalog 可以同时支持 movie 和 series(在 manifest 里声明两次)
10.3 Meta 规范
-
meta.id必须与请求的id一致(关键) -
meta.type是movie或series -
series类型必须有videos数组 -
videos[].id格式:{metaId}:{season}:{episode} -
videos[].season和videos[].episode是数字 -
meta失败时返回{ meta: null },不要返回 debug 信息
10.4 Stream 规范
- 每个 stream 必须有
url/externalUrl/infoHash/ytId之一 -
name是分类名(如 “TryBox · MovieBox”) -
title是流描述(如 “1080P · 文件名”) -
quality是清晰度(1080p/720p/4K/ORG) -
bingeGroup用于剧集质量记忆 - 诊断信息放
url,中文原文不编码
10.5 Subtitle 规范
-
subtitles[].id必须唯一 -
subtitles[].url是字幕下载地址 -
subtitles[].lang是 ISO 639-1 两位码(如zh、en) - 不要返回没有
lang的字幕
10.6 ID 规范
- ID 前缀不含冒号
- ID 前缀短、小写、有意义
- Catalog / Meta / Stream 的 ID 必须一致
-
tt前缀保留给 IMDb ID
10.7 部署规范
- Edge Runtime 零第三方运行时依赖
- 用
APP_PREFIX隔离环境 - 缓存 key 统一前缀
- 绑定自定义域名(国内)
- 加
Cache-Control响应头(CDN 缓存)
本文档最后更新于 2026-10-13,项目地址:fqw000/tryboxStremio
